PostgreSQL offers two JSON storage types with different representation costs. json validates and retains the input text, while jsonb converts it into a decomposed binary value. Both support extracting fields and paths. Choosing between them is primarily a decision about preserving representation, processing documents repeatedly, and supporting the operators your queries need.
Preserve text or preserve a value
A json value retains insignificant whitespace, object key order, and repeated object keys. Its processing functions still need to parse that text, and when duplicate keys occur they treat the last value as operative. Retaining duplicates therefore does not mean every consumer will interpret them the same way. Avoid using duplicate keys as a meaningful part of an application contract.
A jsonb value discards insignificant whitespace and object key order, retaining only the last occurrence of each object key. This statement concerns objects: arrays retain their element order and repeated elements. Array containment has its own matching rules, so containment results must not be mistaken for evidence that the stored array was reordered or deduplicated.
The binary format belongs to PostgreSQL; it is not BSON and is not a portable signing format. The textual rendering of jsonb is not a promise of a particular external canonicalization algorithm. If a signature covers original request bytes, preserve those bytes separately, for example in bytea, and verify against that representation. A json column preserves JSON input text, but client encoding conversion and protocol processing mean it is not a general archive of the original network bytes.
Extraction is available in both types
Both types support -> for a JSON value, ->> for text, and #> or #>> for a nested path. Extracting a field from json does not require first casting the whole document to jsonb. The performance distinction is reparsing stored text versus working with the decomposed representation, not an absence of extraction operators.
Jsonb additionally supports structural containment, key existence, and index strategies such as GIN. A scalar expression extracted from either type can also be a candidate for an appropriate expression index. Index selection still depends on the exact expression, operator, and workload; selecting jsonb alone does not make every nested condition fast. Frequently joined, constrained, or ordered attributes may belong in ordinary typed columns.
Input and processing costs also matter. Jsonb does extra work during conversion and can use more space for some documents. It accepts almost, but not exactly, the same inputs as json: PostgreSQL numeric limits and stricter Unicode handling can reject values accepted as json text. Test representative payloads before converting an existing column, especially unusually large numbers and escaped characters.
When it breaks
Changing json to jsonb can destroy representation that an exporter expected to replay. Changing types also does not enforce an application schema: a valid JSON string, number, or array is still JSON even when the application expects an object. Add the actual structural and business constraints rather than assuming that JSON validation checks required fields or their meaning.
A subtler failure is treating all parsers as interchangeable around duplicate keys. The database’s last-value behavior does not establish what an upstream signer or downstream parser used. Reject ambiguous input at the appropriate boundary when agreement matters. For query results, distinguish missing paths, JSON null, and SQL NULL rather than translating all three into an unexplained empty string.
Check it quickly
Save this as json-types.sql and run psql -X -v ON_ERROR_STOP=1 -f json-types.sql in a disposable PostgreSQL 18 database. It uses one temporary table inside a transaction and rolls everything back.
\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE sample (raw json, parsed jsonb);
INSERT INTO sample VALUES ('{ "b": 2, "a": 1, "a": 3 }','{ "b": 2, "a": 1, "a": 3 }');
SELECT raw::text, parsed::text, raw->>'a' AS json_a, parsed->>'a' AS jsonb_a FROM sample;
SELECT '{"user":{"name":"Ada"}}'::json #>> '{user,name}' AS json_path;
DO $$ BEGIN
IF (SELECT raw::text FROM sample) IS DISTINCT FROM '{ "b": 2, "a": 1, "a": 3 }' THEN
RAISE EXCEPTION 'json text was changed';
END IF;
IF (SELECT parsed FROM sample) IS DISTINCT FROM '{"a":3,"b":2}'::jsonb THEN
RAISE EXCEPTION 'unexpected jsonb value';
END IF;
IF (SELECT raw->>'a' FROM sample) IS DISTINCT FROM '3' THEN
RAISE EXCEPTION 'json extraction did not use the last duplicate key';
END IF;
IF '[2,1]'::jsonb = '[1,2]'::jsonb THEN
RAISE EXCEPTION 'jsonb array order was ignored';
END IF;
IF '[2,1,2]'::jsonb = '[2,1]'::jsonb THEN
RAISE EXCEPTION 'jsonb duplicate array elements were ignored';
END IF;
END $$;
DO $$ BEGIN
PERFORM '{broken'::json;
RAISE EXCEPTION 'invalid JSON accepted';
EXCEPTION WHEN invalid_text_representation THEN RAISE NOTICE 'invalid JSON rejected';
END $$;
ROLLBACK;
PostgreSQL 18.6 returned the original spaced text for raw, a normalized object containing a=3 and b=2 for parsed, and 3 from both extraction expressions. The nested path on json returned Ada directly. The assertions check these semantics and separately check that array equality respects order and repeated elements; the handled invalid-input error prints an “invalid JSON rejected” notice.
This exercise establishes representation and extraction behavior, not a speed or storage benchmark. Compare actual document sizes, write rates, and query plans separately before migrating a production column. Keep original data available until conversion failures and any intentional loss of representation have been accounted for.
Sources
- PostgreSQL JSON types, representation, input restrictions, and indexing, checked 2026-09-10.
- PostgreSQL JSON functions and operators, extraction operators for both types and null behavior, checked 2026-09-10.