Quick summary
Summarize this blog with AI
Introduction
jsonb is one of PostgreSQL's most useful escape hatches. It lets you keep semi-structured payloads close to relational data, validate that the input is JSON, query inside documents, and add GIN indexes when containment searches matter.
It is not a free write path. Large jsonb inserts can slow down because PostgreSQL has to parse JSON text, build the binary representation, maintain indexes, write WAL, and sometimes rewrite large TOASTed values during upserts. If the payload is deeply nested or full of repeated keys, that work becomes visible.
The goal is not to avoid jsonb everywhere. The goal is to know when the insert path is paying for flexibility you actually use, and when a small schema or loading change would remove the bottleneck without making the system harder to maintain.
What JSONB Does at Insert Time
PostgreSQL offers both json and jsonb. The json type stores the input text more directly. The jsonb type stores a decomposed binary representation that is better for processing and indexing later.
That design shifts some cost to writes. On insert, PostgreSQL must parse the JSON input and convert it into the internal jsonb form. On later reads, the database can use jsonb operators and indexes instead of reparsing plain JSON text.
For small payloads, the difference is usually not worth worrying about. For bulk loads of large documents, the conversion, index maintenance, and WAL volume can dominate total time.
PostgreSQL Version and Provider Notes
The broad tradeoff is stable across modern PostgreSQL versions: jsonb costs more at write time than plain scalar columns because PostgreSQL must parse and store a binary representation. Newer versions may improve planner behavior, JSON functions, or general execution performance, but they do not make large JSONB conversion free.
Managed and serverless Postgres providers can add their own shape to the benchmark. Cold starts, network distance, storage tier, connection pooling, and I/O limits can make the same SQL look different from local PostgreSQL. That does not invalidate the test. It means the benchmark should be run where the workload actually runs.
Expected result from a useful benchmark is not a universal milliseconds-per-row number. It is a ranking of bottlenecks: scalar baseline, JSONB conversion cost, index maintenance cost, and upsert rewrite cost under the same environment.
A Practical Benchmark Plan
Before changing the schema, isolate the costs. Run the same row count through a few controlled variants:
| Test | Purpose |
|---|---|
| Scalar columns only | Baseline for client, network, transaction, and storage overhead |
jsonb column, no secondary JSONB index | Approximate JSON parsing and storage cost |
jsonb column with intended indexes | Measure index write amplification |
Insert only vs ON CONFLICT DO UPDATE | Measure conflict checks and rewrite cost |
| Different batch sizes | Find the point where larger batches stop helping |
Use realistic payloads. Random tiny JSON objects will not reproduce a production telemetry document, API payload, profile blob, or event envelope.
For query performance, use EXPLAIN (ANALYZE, BUFFERS). For insert performance, measure elapsed time for each controlled load and inspect database-level metrics if you have them. If pg_stat_statements is enabled, compare total time, mean time, and call count for the insert statement.
If you are still learning execution plans, How to Read SQL EXPLAIN Plans Without Guessing is the companion article for the read side of this problem.
Why COPY Helps, But Does Not Remove JSONB Parsing
COPY and batched inserts reduce client/server round trips:
COPY raw_events (source_id, received_at, payload)
FROM STDIN WITH (FORMAT csv);
That can be a large improvement over thousands of single-row inserts. But COPY still sends values that PostgreSQL must store as jsonb. If JSON parsing and index maintenance are the bottleneck, COPY helps only up to the point where round trips are no longer the limiting factor.
A useful sign: if COPY, a multi-row insert, and a well-batched client helper all land in the same performance range, the bottleneck is probably not just client round trips.
Index Cost: GIN Is Powerful, Not Automatic
A broad GIN index on a whole document supports flexible JSONB searches:
CREATE INDEX events_payload_gin_idx
ON events
USING GIN (payload);
That can be the right index for containment and existence queries. It can also be expensive during writes, especially when every document contains many nested keys and array elements.
If production queries only filter on a few known paths, expression indexes may be smaller and cheaper:
CREATE INDEX events_customer_id_idx
ON events ((payload ->> 'customer_id'));
If the workload mostly uses containment with @>, jsonb_path_ops can be a better fit than the default operator class for that narrower pattern:
CREATE INDEX events_payload_path_ops_idx
ON events
USING GIN (payload jsonb_path_ops);
The rule is simple: index the queries you actually run. Do not add a broad JSONB index merely because a column is jsonb. If index selection is the part that feels uncertain, read Why SQL Indexes Are Not Used.
Move Hot Fields Into Columns
A common mature design is hybrid: keep the original payload in jsonb, but extract stable, frequently used fields into normal columns.
CREATE TABLE events (
id bigserial PRIMARY KEY,
source_id text NOT NULL,
event_type text NOT NULL,
customer_id text,
received_at timestamptz NOT NULL,
payload jsonb NOT NULL
);
This gives PostgreSQL better statistics, simpler indexes, clearer constraints, and cheaper joins for the hot fields. The full payload remains available for audit, replay, debugging, and less common attributes.
A practical signal: if you repeatedly write payload ->> 'customer_id', payload ->> 'event_type', or payload ->> 'account_id' in important queries, that field probably deserves to be a column or generated column.
For the larger modeling decision, the related guide PostgreSQL JSONB vs Columns explains when flexibility starts turning into performance debt.
Reduce Repeated-Key Payloads
Large JSONB documents often spend space repeating key names. For telemetry-style data, this shape repeats keys for every measurement:
{
"measurements": [
{"temperature": 28.5, "pressure": 5.0, "salinity": 34.2},
{"temperature": 28.3, "pressure": 10.0, "salinity": 34.3},
{"temperature": 27.9, "pressure": 15.0, "salinity": 34.4}
]
}
A columnar JSON shape can be smaller:
{
"temperature": [28.5, 28.3, 27.9],
"pressure": [5.0, 10.0, 15.0],
"salinity": [34.2, 34.3, 34.4]
}
This is not automatically better. It can make ad hoc querying less convenient and requires the application to keep arrays aligned. It is worth considering when payloads are large, repeated keys dominate size, and the read patterns tolerate the shape.
Stage as Text When Immediate JSONB Search Is Not Needed
If ingestion latency matters and the system does not need to query inside the payload immediately, a staging table can decouple acceptance from JSONB conversion:
CREATE TABLE raw_event_ingest (
id bigserial PRIMARY KEY,
source_id text NOT NULL,
received_at timestamptz NOT NULL,
payload_text text NOT NULL,
processed_at timestamptz,
processing_error text
);
A background job can validate and convert later:
INSERT INTO events (source_id, received_at, payload)
SELECT
source_id,
received_at,
payload_text::jsonb
FROM raw_event_ingest
WHERE processed_at IS NULL
AND processing_error IS NULL;
This does not eliminate parsing. It moves parsing out of the request path. That can be the right tradeoff for event ingestion, webhooks, logs, or external feeds where accepting the payload quickly matters more than querying it immediately.
Do not let this become a silent failure path. A production version needs validation, error capture, retries, dead-letter handling, and monitoring for rows stuck in staging.
Be Careful With Upserts on Large JSONB Values
This common pattern can rewrite large documents on every conflict:
INSERT INTO profiles (source_id, profile_key, payload)
VALUES ($1, $2, $3::jsonb)
ON CONFLICT (source_id, profile_key)
DO UPDATE SET payload = EXCLUDED.payload;
If repeated identical upserts are common, avoid unnecessary rewrites:
INSERT INTO profiles (source_id, profile_key, payload)
VALUES ($1, $2, $3::jsonb)
ON CONFLICT (source_id, profile_key)
DO UPDATE SET payload = EXCLUDED.payload
WHERE profiles.payload IS DISTINCT FROM EXCLUDED.payload;
This adds a comparison, so benchmark it. It helps most when many conflicts carry unchanged payloads and the update rewrite is more expensive than the comparison.
Generated Columns Can Split the Difference
For stable fields that live inside a payload, a generated column can make the hot field indexable as a normal value:
ALTER TABLE events
ADD COLUMN customer_id text
GENERATED ALWAYS AS (payload ->> 'customer_id') STORED;
CREATE INDEX events_customer_id_generated_idx
ON events (customer_id);
This still computes the value during writes, but it gives read queries a straightforward indexed column and lets the schema document which JSON fields are important.
Normalize Selectively
Full normalization is not automatically faster. If one JSON document becomes tens of thousands of child rows, inserts may become slower and application code may become harder to operate. But normalization is the right answer when nested elements are independently queried, constrained, updated, or joined.
| Keep JSONB | Move to columns or child tables |
|---|---|
| The document is read and written as a whole | Nested fields are filtered or joined often |
| Fields are sparse or unstable | Fields are stable and business-critical |
| The payload is an audit snapshot | Constraints or uniqueness rules matter |
| Only a few extracted fields are hot | Child elements need independent updates |
Batch Size and Transaction Shape
Bigger batches reduce overhead, but very large transactions have costs: longer locks, larger rollback work, more WAL at once, and painful retries if one row fails validation. A reasonable workflow is to test several batch sizes using real payloads and choose the smallest batch size that gets most of the throughput gain.
If a load job runs continuously, predictable chunks are usually easier to operate than one massive transaction.
Production Checklist
- Benchmark scalar-only inserts, JSONB without secondary indexes, and JSONB with indexes.
- Measure insert-only and upsert paths separately.
- Check whether every JSONB index is used by real queries.
- Extract stable fields used in filters, joins, groups, uniqueness, or dashboards.
- Consider generated columns for hot JSON paths.
- Use staging text only with validation, retries, and error visibility.
- Test payload shape changes with real read queries before changing producers.
- Choose batch sizes that balance throughput with lock duration and retry cost.
FAQ
Is JSONB bad for bulk inserts? No. It is simply more work than inserting scalar columns or plain text. The cost is acceptable when you benefit from JSONB validation, operators, and indexing.
Will COPY fix slow JSONB inserts? COPY reduces round trips and is often worth using, but it does not remove JSONB parsing or index maintenance.
Should every JSONB column have a GIN index? No. Add a GIN index only when real queries use operators it can accelerate. Otherwise it adds write cost without read benefit.
Is staging JSON as text safe? It can be safe if delayed validation is acceptable and the pipeline has error handling, retries, and monitoring. It is unsafe if invalid payloads can disappear into staging unnoticed.
When should I stop using JSONB for a field? Move the field out when it is stable, frequently filtered, joined, grouped, constrained, or used for uniqueness.