ADR 0001 — Store partial dates as ISO 8601 strings in one column#
Status: accepted
Decision#
A partial date is stored as its ISO 8601 form, in one text column and nothing else:
Precision |
Stored as |
Example |
|---|---|---|
Year |
|
|
Month |
|
|
Day |
|
|
The column is a
varchar(10). Its collation compares text byte by byte, which is PostgreSQL’sCcollation. The field sets this itself.The field adds no companion columns, and asks the model to declare none.
A part that was not recorded is not stored. Nothing fills it with a first day or a first month.
The years are four digits, 0001 to 9999.
These three forms are EDTF level 0 (ISO 8601-2). Nothing else is accepted.
Why#
A partial date is uncertain, and the column must say so#
A value recorded as “1990” means a single day somewhere in 1990. Nobody knows which day. Any storage that writes a day or a month into the column claims a certainty the data does not have. Someone reading the database, or another tool reading it, takes 1990-01-01 for a recorded 1 January.
Four ways of storing a partial date were weighed against that.
Option |
Why it was rejected |
|---|---|
A timestamp with the precision held in its seconds |
Stores a day and a month nobody recorded ( |
A date column and a precision column |
Honest about the precision, but Django has no field that spans two columns. It needs a hidden companion field added to every model, and two fields in every migration. |
A start date and an end date, or a PostgreSQL |
Turns an uncertain date into an interval whose ends look certain. A reader of |
The ISO 8601 string |
Chosen. |
What the string gives#
It is the standard form. DataCite, schema.org and CSL JSON all accept these three forms as they are, so exporting a value needs no conversion.
Text order is date order. Every part is fixed-width and zero-padded, so comparing two values as text orders them chronologically.
order_by,gteandltein SQL give the same order as the value type in Python. A year sorts before the months in it, and a month before the days in it:1990,1990-06,1990-06-14,1991.Range filters work on one indexed column. “Collected between June 1990 and 23 September 2020” is
value >= '1990-06' AND value <= '2020-09-23', an index range scan.No time zone is involved. The column holds no time, so no lookup converts it.
It reads correctly everywhere. In the database, in
dumpdata, in django-reversion’s history and in any tool that reads the table.
What a filter on a partial date means#
A filter compares strings, and partial dates are uncertain, so some matches are uncertain too. This package documents what each filter matches rather than hiding the question behind a stored range. With the range above:
1990-06-14and2020-09are inside it.1990is outside it, because'1990' < '1990-06'. The sample might have been collected in July 1990, or in March.2020is inside it, because'2020' <= '2020-09-23'. The sample might have been collected in December 2020.
A developer who needs a different rule writes it with the lookups the package provides.
Queries beyond ranges#
Need |
How |
|---|---|
Records from June of any year |
A |
A date axis on a chart |
A |
A year → month → day drilldown |
Grouping by the |
Two things Django provides for date columns are lost:
The admin’s
date_hierarchy, which accepts onlyDateFieldandDateTimeField.Django’s own
TruncandExtractfunctions, which this package replaces with its own lookups.
Performance#
Measured on PostgreSQL 17 with 5,000,000 rows, a third each at year, month and day precision, 1900–2025, one B-tree index on each column, no parallel workers. A timestamp column stored the way the replaced package stored it is the baseline.
Query |
Timestamp |
ISO string |
|---|---|---|
June 1990 to 23 September 2020 (1.2 million matches) |
303 ms |
310 ms |
One month (2,126 matches) |
10 ms |
10 ms |
The year 2020 |
98 ms |
105 ms |
June of any year |
1,297 ms |
933 ms |
Counts per month across a decade |
309 ms |
325 ms |
Counts per year across the whole table |
1,715 ms |
1,445 ms |
The newest 50 |
0.4 ms |
0.3 ms |
The first day of every row |
964 ms |
2,177 ms |
Size on disk, with the index |
207 MB |
220 MB |
Range, year and sorting queries use the index in both.
“June of any year” reads the whole table in both, as Django’s
__monthdoes on a real date.Converting every row to a real date is the one slower case. A chart that groups by month or year first does not pay it.
The string column was measured with the
Ccollation. A prefix match such asLIKE '2020%'uses the index only when the column compares text byte by byte. Under a language-aware collation such asen_US.UTF-8, PostgreSQL cannot use the index for it. Byte order is also the only order guaranteed to match date order. This is why the field sets theCcollation itself. SQLite compares byte by byte by default.
Revisit if#
Uncertain or approximate dates come into scope (
1990?,1990~). The column holds them without a schema change, but text order would no longer match date order.Years before 0001 or after 9999 are needed. Text order breaks once a year is not four digits.
A backend this package supports cannot use an index for a prefix range on a text column.