Date Time Field Value Out Of Range On Load
An INSERT ... SELECT that casts a text column to date stops with 'ERROR: date/time field value out of range: "25/01/2025"' and the hint 'Perhaps you need...
In 10 seconds
A scheduled load that pulls dates from an upstream system worked for months, then started failing during the import step with 'ERROR: date/time field value out of range'. The upstream feed sends dates in European DD/MM/YYYY form, and the load casts the text straight to date. PostgreSQL reads the cast under the session DateStyle, which here is MDY (month first), so '25/01/2025' is interpreted as month 25 and the whole load aborts on the first such row. Worse, the rows that do NOT error are not safe either: every string where the day and month are both 12 or less is parsed silently as the wrong date, with the two fields swapped. The loud error is the lucky part; it is the visible edge of a much larger silent-corruption problem.
Why This Happens
A bare cast such as raw_date::date hands the text to PostgreSQL's date/time parser, which decides field order from the session DateStyle setting. With DateStyle MDY the parser expects month/day/year, so '25/01/2025′ is read as month=25, which is out of range, and 22008 is raised. The exact same parser, given a string where both leading fields are 12 or less, has no way to know the intended order: it applies MDY and quietly produces a valid but wrong date. That is why the symptom is split in two -- a subset errors loudly (leading field over 12) and a much larger subset is corrupted silently.
The order of the fields is a property of the DATA, not of the session, so reading it through a session setting is the root mistake. to_date(text, format) removes the ambiguity by stating the field order explicitly, independent of DateStyle. The order of the fields is a property of the DATA, not of the session, so reading it through a session setting is the root mistake. to_date(text, format) removes the ambiguity by stating the field order explicitly, independent of DateStyle.
Diagnose
Free Tier -- Basic Approach
First question: how many rows cannot parse at all? Count the strings whose leading field is greater than 12, because under MDY that field is read as a month and 13..31 is impossible. This is the loudly-failing subset.
SELECT count(*) AS day_gt_12_rows FROM staging_events WHERE split_part(raw_date,'/',1)::int > 12;
day_gt_12_rows
----------------
5
(1 row)
SELECT count(*) FROM staging_events WHERE split_part(raw_date,'/',1)::int > 12; here it returns 5 -- the day-25 rows.
Fix
Parse with an explicit format instead of a bare cast. to_date(raw_date, 'DD/MM/YYYY') reads the fields in the stated order no matter what DateStyle is set, so every row -- including the day-25 strings -- parses correctly and the load completes. This is the literal capture.
TRUNCATE events_clean RESTART IDENTITY; INSERT INTO events_clean(event_date) SELECT to_date(raw_date,'DD/MM/YYYY') FROM staging_events;
TRUNCATE TABLE INSERT 0 1000
TRUNCATE events_clean RESTART IDENTITY; then INSERT INTO events_clean(event_date) SELECT to_date(raw_date,'DD/MM/YYYY') FROM staging_events; the result is INSERT 0 1000 -- all rows loaded, no ambiguity.
Verify
Confirm the healthy state: all 1000 rows loaded, the day-25 strings became real January and May dates (proving DD/MM was honoured), and a bare MDY cast of a day-25 string STILL raises 22008 -- showing the fix was the explicit format, not a quietly changed global setting.
SET datestyle TO 'ISO, MDY';
loaded_rows
-------------
1000
(1 row)
SELECT count(*) AS loaded_rows FROM events_clean;
loaded_rows
-------------
1000
(1 row)
SELECT event_date FROM events_clean WHERE event_date IN (DATE '2025-01-25', DATE '2025-05-25') ORDER BY event_date;
event_date ------------ 2025-01-25 2025-05-25 (2 rows)
SELECT '25/01/2025'::date;
ERROR: 22008: date/time field value out of range: "25/01/2025"
LINE 1: SELECT '25/01/2025'::date;
^
HINT: Perhaps you need a different "datestyle" setting.
LOCATION: DateTimeParseError, datetime.c:3992
Check count(*) = 1000; confirm the loaded dates 2025-01-25 and 2025-05-25 exist; then SELECT '25/01/2025′::date still errors with 22008, which is the correct, expected behaviour for an ambiguous bare cast.
⏪ Rollback
Everything here is done in a throwaway database on a disposable test environment, so cleanup is just dropping that database; the persistent demo containers are never altered. In a real system the broken load committed nothing (the casting INSERT aborted as a whole), so there is no partial data to undo from the loud-failure case. If an earlier run had used a bare cast on all-low-day data and SILENTLY loaded swapped dates, you cannot tell good from bad after the fact -- the remedy is to re-load the affected batch from the staging text with the explicit-format query, not to patch individual rows. Keep the raw staging text until the parsed result has been validated.
📦 Version Matrix (PG 14--18)
Run the same break-and-fix on PostgreSQL 14, 15, 16, 17, and 18: a bare cast under MDY raises the same date/time range error on every version, and the explicit-format load completes identically (INSERT 0 100 on the 100-row matrix set). The behaviour is uniform across all supported releases.
| Version | Behavior | Version Notes |
|---|---|---|
PG 14.23 |
broken=date/time field value out of range | ✗ Not available -- use stats_reset age |
PG 15.18 |
broken=date/time field value out of range | ✗ Not available -- use stats_reset age |
PG 16.14 |
broken=date/time field value out of range | ✗ Not available -- use stats_reset age |
PG 17.10 |
broken=date/time field value out of range | ✗ Not available -- use stats_reset age |
PG 18.4 |
broken=date/time field value out of range | ✗ Not available -- use stats_reset age |
⚠ Common Mistakes
Stop this incident before it pages you
The free runbook clears today's failure. Pro adds the standing guardrails -- the alert query, the dashboard, and the managed-service knobs -- so it never becomes an incident again.
- ✓ A ready-to-paste alert query with the right thresholds.
- ✓ The topology change that prevents recurrence.
- ✓ The exact knobs on RDS, Azure Flexible Server & Cloud SQL.
Frequently Asked Questions
Why does only some of the data raise the error?
Under MDY the parser treats the first field as the month. Any string whose first field is 13 or more (a day of 13..31) cannot be a month and raises 22008. Strings whose first two fields are both 12 or less parse without error -- but as the wrong date, with day and month swapped.
Should I just run SET datestyle to DMY before the load?
That works for this one load, but it is a session-wide setting that other statements may depend on, and a future client that does not set it will silently break again. Parsing with to_date(raw_date, 'DD/MM/YYYY') keeps the field order attached to the load itself, so it is correct everywhere regardless of DateStyle.
How do I know whether earlier loads were silently corrupted?
Compare the stored dates against the raw staging text using both interpretations. Count rows where raw_date::date (under MDY) differs from to_date(raw_date, 'DD/MM/YYYY'); a non-zero count means a bare cast would have produced different -- wrong -- dates for those rows. Keep the staging text so you can re-derive the correct values.
Does this apply to timestamps too, not just dates?
Yes. The same date/time parser and the same DateStyle govern text-to-timestamp casts, so '25/01/2025 09:00′ has the identical hazard. Use to_timestamp(text, format) with an explicit mask for the same reason.