Polyglot Persistence: Using SQL and NoSQL Together in One Application

A practical framework for choosing between SQL and NoSQL databases based on your data shape and access patterns, not hype.

Most Real Systems Aren’t “SQL or NoSQL”

Once an application grows past a certain size, teams often end up running more than one database type — not out of indecision, but because different parts of the system genuinely have different data shapes and access patterns. This is called polyglot persistence, and done deliberately, it’s a sign of good architecture, not over-engineering.

A Common Pattern: PostgreSQL + Redis + Elasticsearch

  • PostgreSQL — the source of truth for transactional data (orders, users, billing)
  • Redis — caching, sessions, and rate limiting
  • Elasticsearch — full-text search across product catalogs or documents

Keeping Multiple Stores in Sync

The hardest part of polyglot persistence isn’t picking the databases — it’s keeping them consistent. A common approach uses change data capture to propagate writes:

// After a successful write to PostgreSQL
async function updateProduct(id, data) {
  await db.query('UPDATE products SET name = $1, price = $2 WHERE id = $3',
    [data.name, data.price, id]);

  // Propagate to Elasticsearch for search
  await esClient.update({
    index: 'products',
    id: String(id),
    doc: { name: data.name, price: data.price },
  });

  // Invalidate Redis cache
  await redisClient.del(`product:${id}`);
}

A More Robust Approach: Outbox Pattern

Direct dual-writes (as above) can fail halfway — the SQL write succeeds but the Elasticsearch update doesn’t. The outbox pattern writes an event to the same transaction as the primary write, then a separate worker propagates it reliably:

BEGIN;
UPDATE products SET name = 'New Name' WHERE id = 42;
INSERT INTO outbox_events (event_type, payload) VALUES ('product.updated', '{"id": 42}');
COMMIT;
// Worker polls outbox_events and propagates reliably
async function processOutbox() {
  const events = await db.query('SELECT * FROM outbox_events WHERE processed = false LIMIT 100');
  for (const event of events.rows) {
    await propagateToSearchIndex(event);
    await db.query('UPDATE outbox_events SET processed = true WHERE id = $1', [event.id]);
  }
}

When to Add a Second Database

  • Full-text search performance on your primary database is genuinely degrading — not just “might be nice”
  • You need sub-millisecond reads on high-traffic lookups that a relational query can’t reliably hit
  • A specific data shape (graph relationships, time-series) is awkward to model relationally at your scale

The Cost You’re Signing Up For

Every additional datastore adds operational surface area: another system to monitor, back up, secure, and reason about during incidents. Don’t add a second database speculatively — add it when a specific, measured problem justifies the added complexity.

Conclusion

Polyglot persistence works well when each database has one clear job and there’s a reliable mechanism (like the outbox pattern) keeping them in sync. Start with a single relational database, and introduce additional stores only when a concrete access pattern demands it.