The Rigid Schema Trap
A few years ago, I built a multi-tenant e-commerce platform where product needs varied wildly. One merchant sold sneakers with four attributes, while another sold industrial sensors with 65 distinct technical parameters. My first instinct was the classic Entity-Attribute-Value (EAV) pattern. It was a disaster. Queries that should have taken 20ms started hitting 5-second timeouts as the joins piled up.
Having jumped between MySQL, MongoDB, and Postgres, I’ve seen where each shines. In the past, I would have reached for MongoDB the moment a schema became unpredictable. But managing two separate database clusters—relational and document-based—is an operational tax most teams can’t afford. PostgreSQL’s JSONB changes the game. It provides document-store flexibility while keeping the ACID compliance and relational power of Postgres.
Start Storing Data in Under 60 Seconds
Setup is a three-line affair. Unlike the standard JSON type which stores a verbatim copy of the input text, JSONB stores data in a decomposed binary format. Expect a 10-15% overhead on writes in exchange for a massive boost in query speed.
Let’s build a logging system where metadata changes based on the event:
CREATE TABLE event_logs (
id SERIAL PRIMARY KEY,
event_type TEXT NOT NULL,
payload JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Inserting varied data is seamless. We can track a login and a purchase in the same column without pre-defining their fields:
INSERT INTO event_logs (event_type, payload) VALUES
('login', '{"user": "john_doe", "ip": "192.168.1.1", "success": true}'),
('purchase', '{"user": "jane_smith", "amount": 150.00, "items": ["SSD", "RAM"]}');
Extracting data relies on the -> and ->> operators. Use -> if you need a JSON object for further processing, or ->> to grab a raw text value:
SELECT
payload->>'user' AS username,
payload->'items' AS items_list
FROM event_logs
WHERE event_type = 'purchase';
Path Queries and the Power of Containment
Retrieving top-level keys is easy, but real-world data is rarely flat. Path navigation is the workhorse here. If you have a deeply nested configuration, use the #> operator to drill down without chaining multiple operators.
-- Accessing payload -> metadata -> settings -> theme
SELECT payload #> '{metadata, settings, theme}' FROM event_logs;
Containment: The Secret to Fast Filtering
The containment operator (@>) is one of JSONB’s most potent features. It checks if the left-side JSON includes the right-side data. This is significantly more efficient than standard text filtering because Postgres can optimize it with specialized indexes.
-- Find logs for a specific user and status
SELECT * FROM event_logs
WHERE payload @> '{"user": "john_doe", "success": true}';
JSON vs. JSONB: The Final Verdict
Developers often ask which one to use. The JSON type is essentially a text blob; it preserves whitespace but forces Postgres to re-parse the text on every query. JSONB strips whitespace and ignores key order, but it is pre-parsed. Unless you must preserve the exact formatting of the source string, always choose JSONB.
Indexing: Turning Seconds into Milliseconds
When tables grow to millions of rows, querying JSONB without an index causes a full table scan. The performance myth starts here. People claim Postgres is slow for JSON, but usually, they just forgot the GIN index.
The GIN (Generalized Inverted Index)
A GIN index maps every key and value within your document. It turns an 800ms sequential scan into a 2ms lookup. It is the heavy lifter for search operations.
CREATE INDEX idx_event_logs_payload ON event_logs USING GIN (payload);
Be careful: GIN indexes are large. A GIN index on a 10GB table might swell to 3GB, which can slow down INSERT operations. If you only ever query one specific field, like user_id, use a functional B-Tree index instead to save space:
CREATE INDEX idx_payload_user ON event_logs ((payload->>'user'));
Performing Surgical Updates
You don’t need to replace an entire 5MB document just to change one field. Since version 9.5, jsonb_set allows for precise updates mid-stream.
-- Update a specific IP address without touching the rest of the payload
UPDATE event_logs
SET payload = jsonb_set(payload, '{ip}', '"10.0.0.5"')
WHERE id = 1;
Production Guardrails
After implementing JSONB across dozens of high-traffic systems, I follow these four rules to keep performance predictable.
- Don’t over-JSONify: If a field appears in every row and you use it for joins (like
user_id), keep it as a standard column. JSONB is for the “extra” data that varies. - Enforce Schema Integrity: JSONB is schemaless by default, but you can add
CHECKconstraints. Use the?(exists) operator to ensure mandatory fields are present. - Monitor Index Bloat: Large GIN indexes can eventually exceed the size of the table. Use
pg_stat_user_indexesto track usage and identify redundant indexes. - Readable Debugging: Stop squinting at minified blobs. Use
jsonb_pretty(payload)in your CLI for a human-readable view.
-- Example: Require a 'version' key in every payload
ALTER TABLE event_logs ADD CONSTRAINT check_payload_version
CHECK (payload ? 'version');
Postgres is a versatile beast. By leveraging JSONB, you get the flexibility of NoSQL without sacrificing the robustness of a relational system. It allows your data model to evolve as fast as your code, provided you respect the indexes and keep your core relational fields separate.

