SQL vs NoSQL for App Backends: How to Actually Choose
Few architecture debates generate more heat and less light than SQL versus NoSQL. It peaked in the early 2010s, when "relational databases don't scale" became a slogan and a generation of apps put shopping carts in document stores — but the arguments still echo through every "what database should I use?" thread today. Strip out the tribalism and the decision is genuinely straightforward, because the two aren't competing products: they're different trade-offs, most apps clearly sit on one side, and the cost of choosing wrong is paid in bugs years later rather than benchmarks today.
What a relational database gives you by default
Start with what you get from Postgres or MySQL without doing anything special, because after a decade of NoSQL marketing it's easy to forget how much this is:
- A schema. The database enforces that data has the shape your code expects — types, required fields, valid references. Bad writes fail loudly at write time instead of surfacing as mystery bugs at read time, months later, in a different feature written by someone who trusted the data.
- Transactions. Move money between accounts, or create an order plus its line items, and either everything commits or nothing does — even under concurrent load, even mid-crash. Correctness under concurrency is one of the hardest problems in software, and here it's simply handled, so thoroughly that developers forget it's there. You notice it the first time you have to fake it by hand.
- Joins. You can ask questions across your data — "users who bought X but not Y," "average order value by signup month" — without having planned for those questions when you designed the tables. Every future feature request and analytics need silently depends on this.
- Constraints. Foreign keys, uniqueness, and check constraints turn whole categories of data corruption into impossible states rather than bugs to hunt. An orphaned order-line can't exist; a duplicate email can't be inserted; the database is the last line of defense that never sleeps.
The classic knock on SQL — "the schema is rigid" — is mostly outdated in practice. Migrations are routine with modern tooling, and Postgres's jsonb column type gives you document-style flexibility inside a relational database when parts of your data genuinely are free-form: structured tables for the data with relationships, a JSON column for the config blob or third-party payload, queryable and indexable either way. The dichotomy the debate assumes — rigid tables or flexible documents, pick one — hasn't been true for years.
What NoSQL actually buys you
"NoSQL" is an umbrella over very different tools, and being precise about which one you mean is half the decision:
- Document stores (MongoDB, Firestore) hold nested JSON-like documents. They're great when each record is naturally self-contained and read whole — a user's settings blob, a saved design. Firestore in particular earns its place in mobile through realtime listeners and offline sync out of the box — but notice that's a platform feature, not a data-model one; teams choose it for the sync and then live with the document model, which is the right trade only if you know you're making it.
- Key-value stores (Valkey, Memcached) are blazing fast for lookups by key. Nearly always the right cache or session store — and nearly always wrong as your primary source of truth, which its own documentation would tell you.
- Wide-column stores (Cassandra and friends) exist for write volumes and horizontal scale that most products will never see — and impose query-pattern rigidity that only makes sense once you genuinely have that problem.
The honest common thread: NoSQL shines when your access patterns are known in advance, simple, and stable — fetch document by ID, append event, look up by key — and when scale genuinely demands distribution. The costs appear later and off the benchmark: the queries you didn't plan for require restructuring or duplicating data; relationships pushed into application code become your concurrency bugs; and the validation the schema used to do now has to live, correctly, in every code path that writes — including the admin script someone runs at midnight.
The failure mode in each direction
Choosing document storage for relational data is the more common and more expensive mistake. An app with users, orders, products, and payments — data that is nothing but relationships — gets denormalized into documents, with consistency maintained by hand: update the product name, then remember to update it inside every order document that embedded it. It works in the demo, survives the first months, and decays as features multiply, until "why do these two screens show different totals?" is a standing agenda item. I've inherited this; the un-denormalizing migration is nobody's favorite quarter.
The reverse mistake is rarer but real: forcing high-churn realtime presence data, or a pile of heterogeneous third-party payloads with no shared shape, through rigid tables and ceremonial migrations when a document or key-value model fits naturally. If you're fighting the schema on data that has no inherent structure, that's the signal — though remember jsonb often resolves it without leaving Postgres.
A default, and reasons to deviate
For a typical app backend — users, content, transactions, relationships — default to Postgres. It handles workloads it was never stereotypically "for": JSON documents, full-text search, geospatial queries, even vector search via pgvector. It scales further than most products will ever need (vertical hardware is enormous now, and read replicas cover most of the rest), and it keeps every future question about your data answerable with a query instead of a data-restructuring project.
Deviate for specific, articulable reasons: Firestore because offline-first sync is your core requirement and you want it managed; Valkey beside your main database for caching and sessions; a document store because your records truly are independent blobs read whole; a distributed store because you've measured your way past what a single primary plus replicas can do. "Deviate for a reason" also means mixing is normal and boring — Postgres as the source of truth with Valkey in front is the battle-tested shape of a huge share of successful backends, and it's not a compromise; it's each tool doing its actual job.
The takeaway
Ask two questions. Is your data relational — do records reference each other, and do you care that those references stay correct? And do you actually have the scale problem NoSQL distribution solves, or do you just like the vibe of the tools built for companies with a thousand times your traffic? For most apps the honest answers are yes and no — and that combination is a relational database. Default to Postgres, use jsonb where structure genuinely runs out, add Valkey when caching earns it, and reach for specialized stores when a specific, measured need shows up. Boring database choices are how your database stays off the list of things you think about.
Further reading
- PostgreSQL documentation — the depth of what the default gives you is genuinely underappreciated.
- Postgres JSON types — document flexibility without leaving the relational world.
- Caching strategies for app backends — the right job for the key-value store.
- Zero-downtime database migrations — why "rigid schema" stopped being a real argument.