Cookbook/ Data Types & Coercion/ Date Time Field Value Out Of...
Data Types & Coercion · Runbook

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...

🧩 5 steps ⏱ ~15 min 📊 Intermediate 🐘 PostgreSQL 14--18 Last reviewed June 26, 2026

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

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.

SQL
SELECT count(*) AS day_gt_12_rows FROM staging_events WHERE split_part(raw_date,'/',1)::int > 12;
OUTPUT -- captured on real PostgreSQL
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.

This is a Pro lesson

Get every Learning Pathway and cookbook recipe -- grounded in PostgreSQL source code, with diagnostics, fixes, and prevention for each topic.

Continue this lesson to learn:

  • Advanced Approach -- Production-Safe
  • All 36 Learning Pathway lessons
  • 170+ cookbook recipes
  • Source-grounded diagnostics & fixes

Secure checkout Cancel anytime Source-grounded

FIX

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.

SQL
TRUNCATE events_clean RESTART IDENTITY;
INSERT INTO events_clean(event_date) SELECT to_date(raw_date,'DD/MM/YYYY') FROM staging_events;
OUTPUT -- captured on real PostgreSQL
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

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.

SQL
SET datestyle TO 'ISO, MDY';
OUTPUT -- captured on real PostgreSQL
loaded_rows
-------------
        1000
(1 row)
SQL
SELECT count(*) AS loaded_rows FROM events_clean;
OUTPUT -- captured on real PostgreSQL
loaded_rows
-------------
        1000
(1 row)
SQL
SELECT event_date FROM events_clean WHERE event_date IN (DATE '2025-01-25', DATE '2025-05-25') ORDER BY event_date;
OUTPUT -- captured on real PostgreSQL
event_date
------------
 2025-01-25
 2025-05-25
(2 rows)
SQL
SELECT '25/01/2025'::date;
OUTPUT -- captured on real PostgreSQL
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

Trusting the session DateStyle: a bare ::date or ::timestamp cast reads field order from a setting that differs across clients, drivers, and locales, so the same load behaves differently in different places.
Treating the loud 22008 as the whole problem: the rows that error (leading field over 12) are the safe ones. The rows where day and month are both 12 or less are accepted and silently swapped, which is far harder to detect later.
Fixing it by changing the global DateStyle: that makes this load work but can break other loads and queries that relied on the previous setting; the order belongs in the parse call, not the server configuration.
Re-running the bare cast after deleting only the rows that errored: the remaining low-day rows are still being misinterpreted, so you end up with a table full of swapped dates and no error to warn you.
Assuming a four-digit year disambiguates the string: the year position helps, but '05/03/2025′ is still ambiguous between 3 May and 5 March -- only an explicit format or a known DateStyle resolves it.
🔒 Pro · Prevent

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.
Unlock Pro$24.99/mo · $199/yr

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.

References

Keep going

Related & next steps