Databases

When JSONB columns beat a fully normalized schema in Postgres

DB

Postgres's JSONB support is mature enough now that it's a legitimate design choice, not just an escape hatch for data you were too rushed to model properly. A feature this quarter with genuinely variable, user-defined data shapes, custom form fields a client's own customers can configure per account, was the clearest case yet for reaching for it instead of another round of schema migrations.

The core tradeoff is one every team weighing this decision needs to be honest with itself about. For data with a shape that's genuinely unpredictable at design time, and stays unpredictable because users themselves are defining the shape, JSONB with a GIN index gets most of the query performance of normalized tables with far less migration overhead, no `ALTER TABLE` every time a customer adds a custom field, no schema coordination between the application layer and the database layer for something that's inherently dynamic.

Query performance held up well once we had the right indexing strategy in place. A GIN index on the JSONB column let us query for the presence of a specific key or a specific value inside the nested structure with performance close enough to an indexed column on a normalized table that it wasn't the bottleneck anywhere we measured. Where we did pay a cost was in query readability and in analytics; ad hoc reporting queries against JSONB fields, especially ones a non-engineer might write, are noticeably less approachable than querying a well-named normalized column, and we ended up building a small materialized view that flattens the most commonly reported-on fields out of the JSONB blob specifically to keep that reporting path simple.

For anything with a stable, well-understood shape, normalized tables are still the better default, and we'd push back on JSONB for any team reaching for it out of a desire to avoid writing a migration rather than out of a genuine, ongoing need for schema flexibility. The two approaches aren't mutually exclusive within a single table either; a handful of stable, frequently queried fields as normal columns alongside a JSONB column for the genuinely variable remainder gave us the best of both in this case, indexed, readable queries for the common fields, and flexibility for everything else without another migration.

Application-layer validation ended up mattering more with this approach than it would with a normalized schema, since Postgres itself won't enforce that a JSONB column's contents match any particular shape the way a `NOT NULL` constraint or a foreign key would for a normal column. We added a validation layer in the application code, using the same schema definitions we already maintained for the client-facing custom field configuration, to make sure malformed data couldn't make it into the JSONB column in the first place, since catching a shape problem at write time is far cheaper than discovering it later while writing a query that assumed a field would always be present.

Migration and versioning of the JSONB shape itself, since "flexible schema" doesn't mean "no schema evolution ever," needed its own lightweight convention. We settled on tagging each record with a small version marker inside the JSON itself and writing application code that can read both the current and one previous version, which let us evolve the custom-field shape over time without a disruptive one-time backfill of every existing record, a pattern that's worked well enough that we now reach for it by default on any JSONB-backed feature expected to evolve.

← Back to the journal

Have a project in mind?
Let’s talk.

Tell us where you are and where you want to go. We'll map the fastest route between the two.

Currently accepting new clients