# 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 | `YYYY` | `1990` | | Month | `YYYY-MM` | `1990-06` | | Day | `YYYY-MM-DD` | `1990-06-14` | - The column is a `varchar(10)`. Its collation compares text byte by byte, which is PostgreSQL's `C` collation. 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 (`1990-01-01 00:00:00`). The precision can be read only through the field's own code. The column is time-zone aware, so Django's `__year`, `__month` and `__date` lookups convert to the current time zone first. On a site west of UTC, a value stored as 1990 then matches `__year=1989`. | | 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 `daterange` | Turns an uncertain date into an interval whose ends look certain. A reader of `1990-01-01 – 1990-12-31` cannot tell it from a recorded interval. It needs a hidden companion column, or works only on PostgreSQL. | | **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`, `gte` and `lte` in 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-14` and `2020-09` are inside it. - `1990` is outside it, because `'1990' < '1990-06'`. The sample might have been collected in July 1990, or in March. - `2020` is 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 `__month` lookup reading characters 6–7. A year-only value has no month, so it never matches. | | A date axis on a chart | A `__start` lookup, giving the first day of the period as a real `date` in the database. In Python the value type gives the first and last day of its period. | | A year → month → day drilldown | Grouping by the `__year` and `__month` lookups. A year-only value stays at the year level and never appears under a month. | Two things Django provides for date columns are lost: - The admin's `date_hierarchy`, which accepts only `DateField` and `DateTimeField`. - Django's own `Trunc` and `Extract` functions, 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 `__month` does 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 `C` collation. A prefix match such as `LIKE '2020%'` uses the index only when the column compares text byte by byte. Under a language-aware collation such as `en_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 the `C` collation 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.