Skip to content

DynamoDB vs Postgres for SaaS: An Honest Comparison

SaaS applications have a specific set of database requirements that don’t show up in most database benchmarks: they need to isolate data between tenants, run cross-tenant analytics for usage dashboards, enforce per-tenant rate limits, and evolve schemas while keeping all tenants running without downtime.

Both DynamoDB and Postgres handle these, but very differently. I’ve built multi-tenant applications on both. Here’s where each one wins.

The SaaS-specific database requirements

Generic database comparisons focus on throughput and latency. SaaS backends have a different priority stack:

  • Tenant isolation: Tenant A’s data must never be visible to Tenant B, regardless of bugs
  • Per-tenant queries: “Get all subscriptions for tenant X” needs to be fast regardless of how many tenants exist
  • Cross-tenant analytics: Usage reports, churn analysis, billing aggregation span all tenants
  • Operational scale: Onboarding tenant 10,000 can’t require manual database work
  • Schema evolution: Adding a feature column should roll out to all tenants atomically or progressively
  • Cost predictability: A free-tier tenant shouldn’t cost the same as a paying one

These requirements point in different directions. Let’s go through each.

Where DynamoDB wins

Per-tenant query isolation

In DynamoDB single-table design, TENANT#<tenantId> is the partition key. Every query scopes to one tenant’s partition by construction. There is no join to other tenants, no risk of returning another tenant’s rows, and no row-level security policy that could be misconfigured.

// This query physically cannot return another tenant's data
const { data } = await SubscriptionEntity.query
  .byTenant({ tenantId: "tenant_abc" })
  .go()

In Postgres, tenant isolation requires Row-Level Security policies or application-layer WHERE clauses on every query. RLS is the right approach but it requires careful setup: every table needs a policy, every role needs correct defaults, and a missing policy is a data leak. The isolation is enforced at the database level, but it requires more deliberate configuration to get right.

The SaaS Multi-Tenant pattern shows the full key structure: 10 access patterns across 4 entity types, all scoped to tenant partitions.

Serverless and Lambda cost model

SaaS economics require that inactive tenants cost nearly nothing. A free-tier tenant with 5 requests/day should not inflate your infrastructure bill meaningfully.

DynamoDB on-demand pricing charges per read/write unit. A dormant tenant costs nothing. An active enterprise tenant costs proportionally to their usage. This is the SaaS pricing model expressed at the database layer.

Postgres on RDS charges by the hour for the instance regardless of tenant activity. You’re paying for peak capacity across all tenants, not per-tenant actual usage. At 1,000 tenants with wildly varying activity levels, DynamoDB’s pricing model aligns better with SaaS revenue.

Scale without pre-provisioning

Onboarding a new tenant in DynamoDB is a write operation. There’s no CREATE SCHEMA, no CREATE TABLE, no permission grant, no row in a system catalog. You write the tenant’s first record and it exists. Deleting a tenant is a bulk delete of their partition, no schema changes required.

At 10,000 tenants this matters. Postgres schema-per-tenant designs (one Postgres schema per tenant for the strongest isolation) require creating a new schema on each signup, running migrations per-schema on each deploy, and managing a growing number of schemas in pg_catalog. It’s manageable with tooling, but it’s operational work that doesn’t exist in DynamoDB.

DynamoDB Streams for audit and billing

SaaS applications need usage tracking: API calls per tenant, feature usage, storage consumed. DynamoDB Streams feeds every write operation into a Lambda function, which can aggregate usage into a separate billing table or emit events for billing systems (Stripe usage records, for example).

This architecture (operational data in DynamoDB, billing/analytics fed from Streams) is a natural fit. The stream consumer is tenant-agnostic; it processes all tenants’ writes in one Lambda function regardless of tenant count.


Where Postgres wins

Cross-tenant analytics

The usage dashboard question comes up in every SaaS: “How many tenants upgraded last week? What’s the average number of users per plan? Which features have the highest adoption?” These are SQL aggregation queries.

SELECT
  p.plan_name,
  COUNT(DISTINCT t.id) as tenant_count,
  AVG(u.user_count) as avg_users,
  SUM(u.api_calls_30d) as total_api_calls
FROM tenants t
JOIN plans p ON t.plan_id = p.id
JOIN usage_summary u ON t.id = u.tenant_id
WHERE t.created_at > NOW() - INTERVAL '90 days'
GROUP BY p.plan_name
ORDER BY tenant_count DESC;

In DynamoDB, this query requires either a dedicated analytics table (maintained separately via Streams) or a full table scan with application-side aggregation. It’s workable but adds architecture: you’re maintaining a Postgres (or Redshift, or Athena) read model just for analytics while DynamoDB handles operational traffic.

If cross-tenant analytics is a first-class feature (embedded charts, customer-facing usage reports, internal growth dashboards), Postgres serves it directly. DynamoDB serves it through an intermediate layer.

Schema migrations

Adding a column to all tenant data in Postgres is ALTER TABLE tenants ADD COLUMN feature_flag BOOLEAN DEFAULT false. Every tenant is updated atomically. Rollback is ALTER TABLE tenants DROP COLUMN feature_flag.

In DynamoDB (schemaless), adding a “column” means:

  1. New writes include the new attribute
  2. Old records don’t have it
  3. Your application code handles both cases, or you run a migration Lambda to backfill

For a simple nullable flag this is usually fine: just default to false in application code for records that don’t have it. For required attributes or changed key structures, migration complexity scales with item count.

At 10M items across 10,000 tenants, backfilling a DynamoDB attribute is a scan-and-update operation. Doable with a Lambda + DynamoDB Streams pattern, but it’s real work. Postgres migrations at that scale are also non-trivial, but the tooling (pg_migrate, Flyway, Atlas) is more mature.

Ad-hoc querying for customer success

Your CS team will ask questions that aren’t in your access pattern list. “Which tenants haven’t invited a team member?” “Show me all tenants on the free plan who have used the export feature more than 5 times.” “Find tenants who logged in this week but haven’t used the core feature.”

These are ad-hoc SQL queries. In Postgres they’re a few minutes of work. In DynamoDB they require either a full scan (slow, expensive) or a dedicated GSI that you built for exactly this query before anyone asked for it.

Postgres’s flexibility here is a genuine CS and growth advantage. If your team regularly explores data to find activation blockers and churn signals, Postgres makes that exploration fast. DynamoDB forces you to predict the questions in advance.

Complex tenant management operations

Tenant merges, tenant cloning, bulk plan changes across a cohort. Postgres handles these with SQL UPDATE … WHERE and transactions. DynamoDB requires application-layer code that iterates over items, handles pagination, and manages partial failures.

This isn’t a dealbreaker but it’s real operational complexity. The more “administrative” queries your product requires (think: support tooling, migration workflows, bulk operations), the more you feel the absence of SQL.


The scorecard

SaaS concernDynamoDBPostgres
Per-tenant query isolation⚠️ (requires RLS config)
Serverless / per-request cost model
Dormant tenant costFreeOverhead shared
Cross-tenant analytics⚠️ (needs read model)
Schema migrations⚠️ (schemaless, app handles)
Ad-hoc CS/growth queries
Tenant onboarding (no schema work)⚠️ (schema-per-tenant)
DynamoDB Streams for billing⚠️
Bulk tenant operations⚠️
Team learning curveSteepGentle

What I’d actually recommend

Use DynamoDB for SaaS if:

  • You’re on Lambda/serverless and want the natural connection model
  • Tenant isolation correctness-by-construction matters to you
  • Your operational access patterns are stable and well-defined (tenant-scoped queries)
  • Analytics can be served from a separate read model (Streams → warehouse)
  • Free-tier tenants are a significant portion of your user base

Use Postgres for SaaS if:

  • Your team’s SQL fluency is an asset you don’t want to throw away
  • Cross-tenant reporting and analytics are first-class (not afterthoughts)
  • You need the flexibility to add access patterns without GSI planning
  • Your schema evolves frequently and you want migration tooling
  • You’re building a complex admin/ops tooling layer

The common pattern at maturity: DynamoDB for operational SaaS data (subscriptions, users, activity), streamed into Postgres or a data warehouse for analytics. You pay for the operational throughput and isolation guarantees in DynamoDB, and you get SQL’s flexibility for the analytics layer. Two moving parts, but they’re independently scalable.

Most early-stage SaaS applications should default to Postgres. The faster iteration speed and SQL flexibility outweigh DynamoDB’s operational advantages when you’re still finding product-market fit. Switch to DynamoDB (or add it alongside) when your access patterns stabilize and your scale starts justifying it. When not to use single-table design covers the conditions that make DynamoDB the wrong call.


The SaaS Multi-Tenant pattern shows what a DynamoDB SaaS backend looks like end-to-end: 10 access patterns, GSI overloading for cross-tenant queries, and the complete ElectroDB entity definitions. If you’re on DynamoDB and evaluating tenant isolation models, single-table vs table-per-tenant covers the isolation tradeoffs. Building something more specialized? The same DynamoDB-vs-Postgres tradeoffs play out differently for chat and messaging and e-commerce backends.

Tejovanth N

Tejovanth builds on DynamoDB in production: rasika.life, rekha.app, rrmstays. All single-table with ElectroDB.

LinkedIn codeculturecob.com

Related