Cookbook/ Data Types & Coercion/ Invalid Input Syntax For Type Integer...
Data Types & Coercion · Runbook

Invalid Input Syntax For Type Integer On Load

An ETL/import step, a column type change (ALTER ... TYPE integer USING ...), or an ad-hoc INSERT ... SELECT that casts text to integer fails with SQLSTATE...

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

In 10 seconds

A nightly load promotes an import/staging table into a typed target. The staging column holds the value as text (raw_qty), and the promotion casts it: INSERT INTO orders_clean(qty) SELECT raw_qty::integer FROM staging_orders. It fails instantly with 'ERROR: invalid input syntax for type integer: "N/A"' (SQLSTATE 22P02). The source swore the column was numeric, but five rows contain the literal N/A. Because INSERT ... SELECT is a single statement, the first non-numeric value aborts the entire load and the target stays empty -- a few dirty rows block thousands of good ones. The cast is not buggy; it is the first thing in the pipeline that actually checks the data is what the schema claims.

Why This Happens

The integer input function parses each text value into a 32-bit integer and raises 22P02 the moment a value is not a valid integer literal (non-digits, empty string, embedded spaces, thousands separators, decimals, or sentinels like N/A). INSERT ... SELECT runs as one statement inside an implicit transaction, so a single parse failure rolls back the whole load -- there is no partial commit and no row skipping.

The fix is therefore about the data, not the SQL: identify the values the integer parser cannot accept, then either filter them out of the load and quarantine them for review, or map them to a defined value (NULL or a default). Once only valid integers reach the cast, the load completes; the type itself never relaxes, which is exactly why a later bad value is rejected again. Once only valid integers reach the cast, the load completes; the type itself never relaxes, which is exactly why a later bad value is rejected again.

DIAGNOSE

Diagnose

Free Tier -- Basic Approach

Before changing anything, size the problem with one count: how many staging rows are not valid integers, tested with the same anchored pattern the cast requires. Five. The cleanup is small and bounded, not an unknown.

SQL
SELECT count(*) AS non_integer_rows FROM staging_orders WHERE raw_qty !~ '^-?\d+$';
OUTPUT -- captured on real PostgreSQL
non_integer_rows
------------------
                5
(1 row)

SELECT count(*) AS non_integer_rows FROM staging_orders WHERE raw_qty !~ '^-?\d+$'; -- the anchored regex (optional sign then only digits) matches exactly what ::integer accepts, so the count equals what the load will reject.

PRO

Advanced Approach -- Production-Safe

The free count says how many; this premium evidence shows WHICH values and WHERE they are, so you fix the source or map them from facts rather than guessing. Part 1 lists the distinct offending values with frequency (N/A x5); part 2 names the exact staging lines (996-1000) to review or correct.

SQL
SELECT raw_qty AS bad_value, count(*) AS occurrences
FROM staging_orders WHERE raw_qty !~ '^-?\d+$'
GROUP BY raw_qty ORDER BY occurrences DESC;
OUTPUT -- captured on real PostgreSQL
bad_value | occurrences
-----------+-------------
 N/A       |           5
(1 row)
SQL
SELECT line_no, raw_qty FROM staging_orders WHERE raw_qty !~ '^-?\d+$' ORDER BY line_no;
OUTPUT -- captured on real PostgreSQL
line_no | raw_qty
---------+---------
     996 | N/A
     997 | N/A
     998 | N/A
     999 | N/A
    1000 | N/A
(5 rows)

Group the offenders with SELECT raw_qty, count(*) ... WHERE raw_qty !~ '^-?\d+$' GROUP BY raw_qty to see every distinct bad form, then list them with SELECT line_no, raw_qty ...

ORDER BY line_no to pinpoint the rows. The distinct set tells you whether the values are fixable formatting or true sentinels.

FIX

Fix

Split the batch instead of failing it. Load only the rows that are valid integers (INSERT 0 995) and copy the rest into a quarantine table for review (SELECT 5). The good data lands immediately and nothing is silently discarded.

SQL
INSERT INTO orders_clean(qty) SELECT raw_qty::integer FROM staging_orders WHERE raw_qty ~ '^-?\d+$';
CREATE TABLE orders_quarantine AS SELECT * FROM staging_orders WHERE raw_qty !~ '^-?\d+$';
OUTPUT -- captured on real PostgreSQL
INSERT 0 995
SELECT 5

INSERT INTO orders_clean(qty) SELECT raw_qty::integer FROM staging_orders WHERE raw_qty ~ '^-?\d+$'; then CREATE TABLE orders_quarantine AS SELECT * FROM staging_orders WHERE raw_qty !~ '^-?\d+$'; -- the filter and the quarantine predicate are exact complements, so every staging row goes exactly one way.

VERIFY

Verify

Confirm no data was lost and the type is still enforced. loaded (995) + quarantined (5) equals the staged total (1000), and casting a known-bad value directly still raises 22P02 -- proof the integer type was never relaxed, only the dirty rows were diverted.

SQL
SELECT (SELECT count(*) FROM orders_clean)      AS loaded,
       (SELECT count(*) FROM orders_quarantine) AS quarantined,
       (SELECT count(*) FROM staging_orders)    AS staged_total;
OUTPUT -- captured on real PostgreSQL
loaded | quarantined | staged_total
--------+-------------+--------------
    995 |           5 |         1000
(1 row)
SQL
SELECT 'N/A'::integer;
OUTPUT -- captured on real PostgreSQL
ERROR:  invalid input syntax for type integer: "N/A"
LINE 1: SELECT 'N/A'::integer;
               ^

Compare the three counts in one row (loaded + quarantined == staged_total), then run SELECT 'N/A'::integer to confirm it is still rejected with SQLSTATE 22P02 and a LINE pointer at the bad literal.

⏪ Rollback

Nothing to undo from the failed load -- the aborted INSERT ... SELECT leaves the target table exactly as it was (here empty), so there is no partial data to clean up. Recovery is forward: count and inspect the non-numeric staging values, then load only the valid rows while copying the rest into a quarantine table. The only data-affecting steps are the filtered INSERT (adds valid rows) and the quarantine copy (preserves the bad rows); both are additive, and pruning the quarantined rows from staging is safe only because they are already preserved elsewhere.

📦 Version Matrix (PG 14--18)

The behavior is identical on PostgreSQL 14 through 18: casting the dirty batch raises 'invalid input syntax for type integer', and filtering to valid rows loads them cleanly (INSERT 0 95). The integer input parser's 22P02 contract is stable across all supported majors.

Version Behavior Version Notes
PG 14.23 broken=invalid input syntax for type integer ✗ Not available -- use stats_reset age
PG 15.18 broken=invalid input syntax for type integer ✗ Not available -- use stats_reset age
PG 16.14 broken=invalid input syntax for type integer ✗ Not available -- use stats_reset age
PG 17.10 broken=invalid input syntax for type integer ✗ Not available -- use stats_reset age
PG 18.4 broken=invalid input syntax for type integer ✗ Not available -- use stats_reset age

Common Mistakes

Casting the whole batch and hoping it works. INSERT ... SELECT is all-or-nothing: one bad value among thousands aborts everything and loads zero rows. Validate the source before the cast, not after the failure.
Silently dropping the bad rows with a WHERE filter and moving on. The rows that did not load may be real orders with a formatting problem -- quarantine them for review instead of discarding them, so no data disappears without a trace.
Assuming N/A is the only offender. Empty strings, values with leading/trailing spaces, thousands separators (1,000), decimals (3.5), and other sentinels (NULL, -, ?) all fail the integer parser. Use the premium distinct-value report to see every form, not just the first.
Mapping every bad value to 0. Coercing N/A to 0 silently invents data and corrupts sums and averages downstream. Prefer NULL (unknown) or quarantine; use a real default only when the business rule genuinely defines one.
Loosely matching with a regex like ~ '\d' that accepts '3.5' or '12abc'. Anchor the pattern (^-?\d+$) so it matches exactly what the integer cast accepts; otherwise the filter passes rows the cast will still reject.
🔒 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 one bad row block the entire load?

INSERT ... SELECT is a single statement in one (implicit) transaction. When the integer cast hits a value it cannot parse it raises 22P02 and the whole statement rolls back -- PostgreSQL does not commit the rows processed so far or skip the bad one. That is why the target stays empty until the data is cleaned.

The error shows the value but not the row -- how do I find the offenders?

Test the staging text against the same rule the cast enforces: SELECT line_no, raw_qty FROM staging_orders WHERE raw_qty !~ '^-?\d+$'. The anchored regex (optional sign, then only digits) matches exactly what ::integer accepts, so it surfaces every value the cast would reject, with its line number.

Should I drop the bad rows or map them to a value?

Neither blindly. Quarantine them (copy to a side table) so nothing is lost, then decide per value: a fixable formatting issue (spaces, thousands separators) can be corrected and reloaded; a true 'unknown' should map to NULL, not 0; only map to a default when the business genuinely defines one. The premium distinct-value report tells you which case you are in.

Can I make the load skip bad rows automatically instead of failing?

Filter to valid rows in the SELECT (WHERE raw_qty ~ '^-?\d+$') so only parseable values reach the cast, and route the rest to quarantine in the same run. For very dirty feeds, COPY supports row-level error handling (ON_ERROR / log) in newer PostgreSQL, but a typed staging column plus a pre-load guard is the durable fix.

How do I stop this recurring every night?

Validate on arrival: give staging a typed column or a CHECK so non-numeric text never lands, and fix the upstream export that emits N/A. Keep a pre-load guard -- SELECT count(*) FROM staging WHERE raw_qty !~ '^-?\d+$' -- that must read 0 before a batch is promoted, and alert when it does not.

References

Keep going

Related & next steps