Now-Next

SaaS architecture

invalid input syntax for type json in PostgreSQL, measured

What raises 'invalid input syntax for type json' (22P02), measured on PostgreSQL 16 to 18: JS arrays, Python lists, NUL escapes, bad rows and a safe ALTER.

Chris van Eijk, Now-Next · · 12 min read

Now-Next builds SaaS products and takes over existing ones. What we build

ERROR: invalid input syntax for type json means PostgreSQL was handed text to turn into json or jsonb, and the text is not JSON that type accepts. The message says type json for a jsonb column too, its SQLSTATE is 22P02 (invalid_text_representation), and DETAIL names the first token the parser could not use, such as Token "'" is invalid. A cast, an insert with a parameter or an ALTER does not tell you the table, column or row; only COPY adds them to CONTEXT.

We fed 38 strings to ::json and ::jsonb on PostgreSQL 16.15, 17.11 and 18.6, sent JavaScript and Python values through node-postgres and psycopg, and migrated a text column with bad rows to jsonb, on a table of nine rows and on one of a million. For the casts, every message, DETAIL and SQLSTATE was the same on all three versions; only the source line under LOCATION differed. One workaround did change in PostgreSQL 18, and we come to it below.

Which inputs raise it, and what does DETAIL say?

Most of what fails is JSON-shaped text from somewhere that is not a JSON serialiser: a Python str() of a dict, JavaScript’s NaN or undefined, a PostgreSQL array literal, a trailing comma. From pg_input_error_info, with the same result from a real cast:

InputjsonjsonbDETAIL
{'a': 1, 'b': 'x'} (Python str(dict))22P0222P02Token "'" is invalid.
NaN, or {"a": NaN}22P0222P02Token "NaN" is invalid.
{"a": undefined}22P0222P02Token "undefined" is invalid.
{"a": True}22P0222P02Token "True" is invalid.
{a: 1} (unquoted key)22P0222P02Token "a" is invalid.
[1, 2,]22P0222P02Expected JSON value, but found "]".
{"a": 1,}22P0222P02Expected string, but found "}".
'', only spaces, or {"a": [1, 222P0222P02The input string ended unexpectedly.
{"a","b"} (a PostgreSQL array literal)22P0222P02Expected ":", but found ",".
{1,2} (a PostgreSQL array literal)22P0222P02Expected string or "}", but found "1".
{\"a\": 1} (escaped quotes, no outer quotes)22P0222P02Token "\" is invalid.
"\x41"22P0222P02Escape sequence "\x" is invalid.
a literal newline inside a string22P0222P02Character with value 0x0a must be escaped.
{"n": 01}22P0222P02Token "01" is invalid.
{"a": 1} {"b": 2}22P0222P02Expected end of input, but found "{".
a byte-order mark before {"a": 1}22P0222P02Token " + the invisible character + " is invalid.
"\ud800" (an unpaired surrogate)accepted22P02Unicode low surrogate must follow a high surrogate.
"\u0000"accepted22P05\u0000 cannot be converted to text.
{} (an empty PostgreSQL array literal)acceptedacceptedan empty object
"{\"a\": 1}" (double-encoded)acceptedaccepteda JSON string, not an object
{"a": 1, "a": 2}accepted, both keys kept{"a": 2}

SQL NULL cast to either type gave NULL, and the JSON text null gave JSON null, both without an error.

The primary message is invalid input syntax for type json for ::jsonb as well, so a search for “invalid input syntax for type jsonb” finds no message PostgreSQL actually writes. The one exception in the table is \u0000 in jsonb: a different SQLSTATE, 22P05 (untranslatable_character), and a different message, unsupported Unicode escape sequence. Code that catches 22P02 does not catch that one. Nor does it catch a number too large for numeric: {"a": 1e1000000} is valid json, and as jsonb it gave 22003, value overflows numeric format. Where both codes sit among the others is in our overview of SQLSTATE codes.

Where in the text is the error?

CONTEXT shows where the parser stopped, not where the value came from. With a 28,914-character input and the error at the end, it read JSON data, line 1: ...94, 4995, 4996, 4997, 4998, 4999, 5000], "bad": x.... In all three long inputs we tried, the part between the leading and trailing dots was 49 bytes and ended on the bad token: 49 characters of ASCII, but only 30 when they were all é. Nothing after the error is shown. line N counts line breaks: an error on the third line of a pretty-printed document said line 3.

Through a driver there is one more line, unnamed portal parameter $1 = '...', and the value is not in it. That is the default log_parameter_max_length_on_error = 0: with set log_parameter_max_length_on_error = -1 in the session, the same error showed $1 = '{"1","2"}', as it did in our uuid measurement.

Why does node-postgres send an array as invalid JSON?

Because node-postgres turns a JavaScript array into a PostgreSQL array literal before it knows where the value goes. Its documentation says so for parameters in general, “Converted to a string that describes a Postgres array”, and says that objects get JSON.stringify. Neither the page on queries nor the page on types warns that this applies to a json or jsonb column too. With pg 8.23.1 against PostgreSQL 18.6, [1, 2] went out as {"1","2"}, and the insert failed with 22P02 and DETAIL: Expected ":", but found ",".: the parser read the braces as the start of an object. An array of objects and a nested array failed with 22P02 as well. A json column, an explicit $1::jsonb and Kysely 0.29.8, which passed the array to pg unchanged, gave the same result.

The empty array is worse, because it works. [] went out as {}, which is valid JSON, and was stored as an empty object, in a json and in a jsonb column, with no error. Code that reads the value back expecting an array gets {}. An empty array inside an object is not affected: {a: []} went through JSON.stringify and was stored as {"a": []}.

A JavaScript value passed as $1 to a jsonb column: what node-postgres 8.23.1 sent and what PostgreSQL 18.6 did with it

Is the value an array?

  • Yes

    Is it empty?

    • No22P02Sent as a PostgreSQL array literal, [1, 2] as {"1","2"}. Expected ":", but found ",".
    • YesStored as {}, an empty objectNo error. The array literal for an empty array is also valid JSON.
  • No

    Is it a plain object?

    • YesStored, through JSON.stringifyBut {a: NaN} arrived as {"a":null}, and the key in {a: undefined} was gone. No error for either.
    • No

      Is it a string that holds JSON?

      • YesStored as writtenA string that was stringified twice is stored as a JSON string, not as an object. In a jsonb column a \u0000 in the string gives 22P05.
      • NoDepends on the value'hello', NaN and a Date: 22P02. true: stored as JSON true. undefined: SQL NULL.

Measured with 17 values in both a json and a jsonb column; every branch came out the same in both, except a string with \u0000, which the jsonb column refused with 22P05. A Date went out as 2026-11-12T00:00:00.000+00:00 and failed on Expected end of input, but found "-11".

The fix is to serialise arrays yourself: JSON.stringify([1, 2]) was stored as the array [1,2], in both column types and through Kysely. For objects that is what node-postgres already does, so the silent changes in the figure, NaN to null and keys with undefined dropped, are JSON.stringify’s and happen whichever way you send it.

What does psycopg do with a Python list or dict?

It depends on what is in the list. With psycopg 3.3.8 and no wrapper, [1, 2] was refused with 42804, column "jb" is of type jsonb but expression is of type smallint[], the error we take apart in column is of type jsonb but expression is of type text. ['a', 'b'] raised 22P02 with Token "a" is invalid. And [] was stored as {}, an empty object, with no error, as in node-postgres. Three outcomes for one type. psycopg’s dumper turned [] into the text {} with no type (OID 0), ['a', 'b'] into {a,b} with no type, and [1, 2] into {1,2} typed as smallint[]; that explains all three outcomes. We read that from the dumper and did not capture the wire.

Parameter, d = {"a": 1, "ok": True, "none": None}In a json or jsonb column
str(d)22P02, Token "'" is invalid.
f"'{json.dumps(d)}'", with extra quotes22P02, Token "'" is invalid.
d without a wrapperpsycopg.ProgrammingError: cannot adapt type 'dict' using placeholder '%s' (format: AUTO), from the client, with no SQLSTATE
json.dumps(d), Json(d) or Jsonb(d)stored as {"a": 1, "ok": true, "none": null}
Jsonb([1, 2])stored as the array [1, 2]
Jsonb({"x": float("nan")}) or json.dumps of the same22P02, Token "NaN" is invalid.
json.dumps(json.dumps(d))stored, but as a JSON string

So wrap the value in Jsonb(...), or Json(...) for a json column, and never build the text with str(). The wrapper does not protect you from NaN: a float nan inside the value still reached PostgreSQL as NaN. The wrapper serialises with Python’s json.dumps, which writes NaN by default; the dumper produced {"x": NaN}.

Why does jsonb reject a value that json accepted?

Because jsonb checks more. The documentation says json checks Unicode escapes “only for syntactic correctness”, while jsonb “rejects \u0000 (because that cannot be represented in PostgreSQL’s text type)” and insists on correct surrogate pairs. We saw exactly that: "\u0000" and "\ud800" were valid json and invalid jsonb.

The catch is that json does not keep you out of trouble, it postpones it. {"a": "x\u0000y"} went into a json column without an error and came back unchanged. Then ->> 'a' failed with 22P05, and so did -> 'a', which returns json and not text. So a migration from json to jsonb can stop on rows that have been read and written without complaint for years: casting that column to jsonb failed with 22P05. node-postgres produces such a row from an ordinary string: JSON.stringify({a: 'x\u0000y'}) was accepted by a json column and refused by a jsonb column with 22P05. Other differences between the two types are in json vs jsonb, measured.

How do you find the rows that are not valid JSON?

With pg_input_is_valid and pg_input_error_info, against the type you are migrating to. Both returned an answer for every row, including 22P05, instead of stopping the query:

select l.id, e.sql_error_code, e.message, e.detail
from legacy l, pg_input_error_info(l.payload, 'jsonb') e
where e.sql_error_code is not null;

On our nine-row table that listed four rows: {'a': 1} and {"a": NaN} with 22P02, "\u0000" with 22P05 and the empty string with 22P02. Rows with NULL gave NULL in every function, so a where not pg_input_is_valid(payload, 'jsonb') leaves them out.

IS JSON looks like the same check and is not. Over all 38 inputs it gave exactly the answers of pg_input_is_valid(txt, 'json'), and differed from pg_input_is_valid(txt, 'jsonb') on three: "\u0000", {"a": "x\u0000y"} and "\ud800". IS JSON called them valid, and a cast to jsonb failed on all three. If you check with IS JSON before a migration to jsonb, those rows pass the check and stop the ALTER. The same goes for a number beyond the range of numeric, which IS JSON accepted and jsonb refused with 22003. IS JSON WITH UNIQUE KEYS is the only one of the four that rejects duplicate keys, which jsonb accepts by keeping the last value. It also returned false for "\u0000" and "\ud800", which have no keys at all; the documentation of the predicate does not mention that. Our guess is that it decodes the strings and runs into the same Unicode check without raising it, but we did not look into that.

What the checks cost, as a count(*) over a table of 1,000,000 rows of about 60 bytes, 110 MB, with 10,000 invalid rows, everything in memory after a warm-up scan, one run per version:

CheckPG 16.15PG 17.11PG 18.6
warm-up, payload is not null29 ms28 ms30 ms
payload is not json114 ms117 ms115 ms
not pg_input_is_valid(payload, 'json')122 ms123 ms122 ms
payload is not json with unique keys245 ms250 ms248 ms
not pg_input_is_valid(payload, 'jsonb')302 ms311 ms301 ms
pg_input_error_info(payload, 'jsonb'), grouped by message736 ms717 ms736 ms

IS JSON was about 2.6 times as fast as the jsonb check, and gave the wrong answer for jsonb on the three inputs above. A few hundred milliseconds to list the bad rows in a million is cheap next to one failed ALTER per bad row, as the next section shows.

Why does ALTER COLUMN TYPE jsonb fail, and what does a failure cost?

alter table legacy alter column payload type jsonb using payload::jsonb stops at the first row it cannot cast, and undoes everything:

ERROR:  22P02: invalid input syntax for type json
DETAIL:  Token "'" is invalid.
CONTEXT:  JSON data, line 1: {'...

Afterwards the column was still text with all nine rows. In a transaction that first updated a row and then ran the failing ALTER, the COMMIT ended as a ROLLBACK and the update was gone too. The message names no table, column or row id, only the start of the JSON. It is also only about the first invalid row the scan reached, here id 3; the rows with NaN, \u0000 and the empty string did not show up. Fixing and retrying one row at a time means one failed ALTER per bad row, and how long each one takes depends on where that row is.

ALTER COLUMN payload TYPE jsonb on about a million rows: milliseconds until it failed or finished, PostgreSQL 18.6
Rows and USINGMilliseconds
Fails: 1,000,000 rows, the first invalid one is row 100, on the second page, payload::jsonb1.5
Fails: 990,000 rows, one invalid row stored last, payload::jsonb1,619
Succeeds: 990,000 valid rows, payload::jsonb1,800
Succeeds: 990,000 valid rows, guarded with pg_input_is_valid2,643
Succeeds: 1,000,000 rows, 1% invalid and set to NULL, guarded2,644

Bar length is linear, against the longest run. One run per variant, warm cache, default configuration. PostgreSQL 16.15 and 17.11 gave the same order; the failure at the end took 1,660 and 1,608 ms there, the plain successful ALTER 1,911 and 1,786 ms, and the early failure took between 1.4 and 15 ms, varying more between runs than between versions.

A failure near the end of the table cost almost as much as a successful rewrite. The guard made a successful ALTER on 990,000 valid rows about 0.8 to 0.9 seconds slower, roughly 40 to 50%. The likely reason is that every row is parsed twice, once to check and once to cast; we did not measure that separately.

How do you cast text to jsonb without failing on bad rows?

To get NULL instead of an error, you put pg_input_is_valid in front of the cast. Where you put that check turned out to matter, and on PostgreSQL 18 one form became unreliable. All four variants use case when pg_input_is_valid(t, 'jsonb') then t::jsonb end:

VariantOn a column with invalid rowsWith a constant invalid input
PL/pgSQL function, immutableworks on 16, 17 and 18works on 16, 17 and 18
SQL function, immutableworks on 16 and 17; on 18 fails when an invalid value comes in one of the first five calls in the sessionthe same
SQL function, stable or volatileworks on 16, 17 and 18fails on 16, 17 and 18
the case written out in the queryworks on 16, 17 and 18fails on 16, 17 and 18

“Works” means NULL for each invalid row. “Fails” means the 22P02 the function was written to avoid. Neither strict nor a SQL-standard RETURN body changed anything.

On PostgreSQL 18.6 the immutable SQL function failed on our nine-row table, with CONTEXT: ... SQL function "try_jsonb_sql" statement 1, where 16.15 and 17.11 returned nine rows. It did not always fail. In a new session it failed when the invalid value came in one of the function’s first five calls, and from the sixth call on it returned NULL, also in later queries in the same session. An ALTER on 100,000 rows whose first invalid row was id 100 succeeded with it. With plan_cache_mode = force_custom_plan it failed on 18 also when the invalid row came eighth, and still worked on 16 and 17. With force_generic_plan it always worked. Whether your migration stops therefore depends on what the connection did before.

That is a planner change. The PostgreSQL 18 release notes list it under the optimizer, not under the incompatibilities: “Improve SQL-language function plan caching” (release notes). The commit behind it says the old plans “could not be customized to specific values of the function’s parameters”. Our reading is that a custom plan puts the argument into the body as a constant, and the planner then evaluates the immutable cast before the case can skip it. The documentation warns about exactly this, that “the values of function arguments and local variables can be inserted into queries as constants for planning purposes” (§4.2.14). The five calls match the plan cache’s rule that “the first five executions are done with custom plans” (PREPARE). We infer that the same rule applies here and did not trace it in the source. Inlining fits what we saw: pg_input_is_valid is itself stable, and EXPLAIN VERBOSE showed the stable and volatile SQL functions written out as a case in the plan and the immutable one as a call, try_jsonb_sql(payload).

The variant that held up in every case we measured is PL/pgSQL. The same section of the documentation recommends it: “using an IF-THEN-ELSE statement to protect a risky computation is much safer than just nesting it in a CASE expression.”

create or replace function try_jsonb(t text) returns jsonb
language plpgsql immutable
as $$
begin
  if pg_input_is_valid(t, 'jsonb') then return t::jsonb; end if;
  return null;
end $$;

alter table legacy alter column payload type jsonb using try_jsonb(payload);

That ALTER succeeded on all three versions, and so did using case when pg_input_is_valid(payload, 'jsonb') then payload::jsonb end, the form in the figure above. The PL/pgSQL function is slower: in one rerun, on the 990,000-row table with the invalid row last, it took about 3.2 seconds on each version, against 2.6 for the case and 1.8 for the plain cast on the 990,000 valid rows in the figure. Both turn every invalid row into NULL, which is a decision about your data and not a fix. List the rows with pg_input_error_info first and decide per kind of error: a Python repr can be repaired, an empty string probably means NULL, and a \u0000 needs a choice about what the character should become.

Which mistakes do not raise the error at all?

The ones that happen to be valid JSON:

  • An empty array from a driver became {}, an empty object, in node-postgres and psycopg.
  • A double-encoded document, "{\"a\": 1}", is a valid JSON string. ->> 'a' on it returned NULL without an error; (payload #>> '{}')::jsonb ->> 'a' unwrapped it and returned 1.
  • Duplicate keys went into jsonb with only the last value kept.
  • NaN inside a JavaScript object became null through JSON.stringify.
  • NaN and Infinity from SQL: to_jsonb('NaN'::numeric) returned the JSON string "NaN", and to_jsonb('Infinity'::float8) the string "Infinity". No error, but a number has become a string.

What does this error look like in your driver?

For a literal in the SQL, SQLSTATE 22P02 is the same in every client; the exception and the wording around the message are not. We raised this error from twenty client versions on PostgreSQL 17.11, with the same result on 18.6; the table shows sixteen. The … is PostgreSQL’s own text, here invalid input syntax for type json, and <sql> your statement.

ClientExceptionThe line reads
psycopg2, psycopg 3, asyncpg, Djangopsycopg2.errors.InvalidTextRepresentation, psycopg.errors.InvalidTextRepresentation, asyncpg.exceptions.InvalidTextRepresentationError, django.db.utils.DataError…
SQLAlchemy on psycopg2sqlalchemy.exc.DataError(psycopg2.errors.InvalidTextRepresentation) …
node-postgresDatabaseErrorerror: …
Prisma 7, raw queryPrismaClientKnownRequestErrorRaw query failed. Code: `22P02`. Message: `…`
Prisma 6, raw queryPrismaClientKnownRequestErrorRaw query failed. Code: `22P02`. Message: `ERROR: …
pgJDBCorg.postgresql.util.PSQLExceptionERROR: …
Spring JdbcTemplateorg.springframework.dao.DataIntegrityViolationExceptionStatementCallback; SQL [<sql>]; ERROR: …
Hibernate 7 logorg.hibernate.exception.DataExceptionHHH000247: ErrorCode: 0, SQLState: 22P02
Hibernate 6 logorg.hibernate.exception.DataExceptionSQL Error: 0, SQLState: 22P02
pgx*pgconn.PgErrorERROR: … (SQLSTATE 22P02)
lib/pq 1.12*pq.Errorpq: … at column N (22P02)
Rails ActiveRecordActiveRecord::StatementInvalidPG::InvalidTextRepresentation: ERROR: …
PHP PDOPDOExceptionSQLSTATE[22P02]: Invalid text representation: 7 ERROR: …

The Hibernate lines are the first of two log lines; the second carries the message, at ERROR on 6.6 and WARN on 7.4. Prisma 6’s message runs on over the DETAIL line, hence no closing backtick in its row. lib/pq 1.12 adds the position in your statement (at column N) and the code; 1.10 printed neither.

With the same text bound as a string parameter (VALUES (2, $1)), fourteen clients got the same 22P02. pgJDBC, JdbcTemplate and Hibernate got 42804 instead, column "payload" is of type jsonb but expression is of type character varying, and Prisma 6 the same with text; JdbcTemplate then shows only bad SQL grammar on its first line, with the message on the Caused by: line: the error you search for then is column is of type jsonb but expression is of type, and the invalid JSON is only found once that is fixed.

Which field holds the SQLSTATE per driver is in our overview.

Reproduce it

Two files: setup.sql holds the 38 inputs, the casts, the checks, the migration and the safe casts, and with -v bench=1 the million-row timings; run.sh starts a container per PostgreSQL version, runs it, runs the node-postgres and psycopg tests against the newest version, and removes everything again. It needs Docker; BENCH=0 ./run.sh skips the million rows. The output lands in a results folder next to the script.

What we did not measure

Databases with an encoding other than UTF8, where the documentation says jsonb rejects more. How Drizzle, TypeORM and the ORM layers of Prisma, SQLAlchemy and Django serialise a value before it reaches the driver; the driver table covers raw SQL only. What psycopg put on the wire. COPY beyond the one message above, including ON_ERROR in PostgreSQL 17, json_populate_record, and JSON_QUERY with ERROR ON ERROR. Where in the source PostgreSQL 18 decides between a custom and a generic plan for a SQL function, and whether the five calls are always five. The lock an ALTER ... TYPE jsonb takes, and for how long. For the timings: one run per version, plus one rerun that reproduced them within about 4% apart from the early failure, no cold cache, no large documents and no concurrent load. The cost of the PL/pgSQL function comes from that single rerun.

Sources: PostgreSQL 17 documentation on JSON types, on pg_input_is_valid and pg_input_error_info, on IS JSON, on expression evaluation rules (§4.2.14) and on PREPARE; the PostgreSQL 18 release notes and commit 0dca5d68d; node-postgres documentation on queries and types, all read on 12 November 2026. All measurements are our own, on PostgreSQL 16.15, 17.11 and 18.6 in throwaway containers, with node-postgres 8.23.1, Kysely 0.29.8 and psycopg 3.3.8 against 18.6, on 12 November 2026, with a rerun the same day. Driver table on 17.11 and 18.6, versions in the README, on 15 November 2026.

Let's talk

What are we building?

One conversation is enough to know whether we fit. Tell us what you have in mind — we will tell you how fast it can happen, and whether we are the right people for it.

Start the conversation