Building Blocks Intermediate 5 min read

SQL vs NoSQL for .NET Apps: How to Choose

How to pick a database for a .NET service: when Postgres scales further than you think, when document stores win, and when read replicas help.

Table of contents
  1. When does the default - PostgreSQL + EF Core - run out?
  2. What numbers should I budget for the database tier?
  3. What does the minimal architecture look like?
  4. How does EF Core wire up to a primary + read replica?
  5. When should I shard, and what does it cost?
  6. What failure modes does the database tier introduce?
  7. When is NoSQL actually the right answer?
  8. Where should you go from here?

The database choice is the most expensive design decision in any .NET service - not because the wrong database is slow, but because moving off it later means rewriting the data access layer, the schema migration tooling, the test setup, and often the consistency model. This chapter gives you a decision tree that bottoms out in "PostgreSQL plus EF Core" for 80% of services and explains exactly when to deviate.

When does the default - PostgreSQL + EF Core - run out?

Three concrete signals.

Single-node write QPS pegs at the box ceiling. A modern Postgres instance handles 5K-20K write/s comfortably; with synchronous_commit = off and SSD it hits 50K. When peak write QPS from chapter 2 approaches that, the database is the bottleneck.

Storage exceeds ~1 TB of hot data per node. Postgres handles much more, but query plans on huge tables become harder, vacuums slow down, and backups balloon. Sharding or columnar storage starts to matter.

Working set exceeds RAM. The "boring" Postgres scaling story relies on the OS page cache holding the hot pages. Once your working set is 10x your RAM, every query hits disk and latency collapses.

If none of these are true, you are not running out of Postgres - you are running out of patience or marketing.

What numbers should I budget for the database tier?

Operation                            Latency       Cost/QPS
Postgres SELECT by PK                ~1-3 ms       cheap
Postgres SELECT with 1 join          ~5-10 ms      medium
Postgres SELECT with aggregate       ~20-200 ms    expensive
Postgres INSERT (single row)         ~1-3 ms       cheap
Postgres INSERT (batched 100)        ~5-10 ms      cheaper per row
EF Core SaveChanges (small batch)    +~1-2 ms overhead
DynamoDB GetItem                     ~5-10 ms      flat
MongoDB findOne by index             ~2-5 ms       cheap

The key intuition: the engine is cheap, the .NET overhead is small, the cost is in the query shape. A poorly-indexed join on a million rows costs more than a well-indexed cross-collection lookup in MongoDB. Schema design matters more than database choice for most .NET services.

What does the minimal architecture look like?

flowchart LR
    App1[ASP.NET Core 1] --> PG[(Postgres primary<br/>writes)]
    App2[ASP.NET Core 2] --> PG
    App1 -.read-only.-> RR[(Read replica)]
    App2 -.read-only.-> RR
    PG -. async replication .-> RR

Two replicas of the web tier behind a load balancer; one Postgres primary handles writes; one read replica serves dashboards and analytical reads. The architecture covers ~80% of services through the first three years of growth. Sharding, document stores, and multi-region come after you have outgrown this.

How does EF Core wire up to a primary + read replica?

Two DbContext instances pointed at different connection strings:

// Primary - writes and read-after-write reads
builder.Services.AddDbContextPool<AppDbContext>(opt =>
    opt.UseNpgsql(builder.Configuration.GetConnectionString("Primary")));

// Read-only replica context
builder.Services.AddDbContextPool<AppReadDbContext>(opt =>
    opt.UseNpgsql(builder.Configuration.GetConnectionString("Replica"))
       .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

// Usage:
public class ProductController(AppDbContext write, AppReadDbContext read) : Controller
{
    [HttpGet]
    public Task<List<Product>> ListAsync()
        => read.Products.AsNoTracking().ToListAsync();   // hits replica

    [HttpPost]
    public async Task CreateAsync(Product p)
    {
        write.Products.Add(p);
        await write.SaveChangesAsync();                  // hits primary
    }
}

Two design rules. First, never write through the read context - it will silently work in dev and explode in prod when the replica is read-only. Second, immediately after a write, read from the primary for that user (the stickiness cookie from chapter 3) - replication lag is real and visible.

When should I shard, and what does it cost?

Sharding splits one logical table across multiple physical databases by some key. The trade-offs are sharp.

Pros: write throughput scales linearly with the number of shards; storage scales linearly; failures are isolated to one shard.

Cons: every query must include the shard key; cross-shard joins become application-layer code; transactions are local-only; schema migrations multiply.

The .NET ecosystem reaches sharding through Citus (a Postgres extension that turns it into a distributed system) or through manual sharding with one DbContext per shard plus a routing layer. Both are heavy. Default to delaying sharding until the metrics from chapter 13 demand it.

What failure modes does the database tier introduce?

The four that show up first:

When is NoSQL actually the right answer?

Two cases that are clear-cut.

Case 1: hot key write throughput beyond a single Postgres node. A globally distributed key-value workload (session cache, real-time counters, IoT telemetry) where every write is independent fits Cassandra or DynamoDB perfectly. Postgres requires sharding to keep up; NoSQL was designed for it from day one.

Case 2: schema variability across tenants. A SaaS that lets each customer add custom fields ends up with JSONB columns in Postgres or - more honestly - documents in MongoDB. The schema is the data; forcing it into rows is rowing upstream. The caching layer covers small KV needs even before this point.

Where should you go from here?

Next chapter: message queues for .NET - when synchronous database writes are the wrong shape and you need to buffer through a queue. After that, the rest of the building blocks compose with this database tier as the durable home for state.

Frequently asked questions

Why is Postgres the default and not SQL Server?
Mostly licensing and managed-cloud parity. Postgres runs everywhere - Azure Database for PostgreSQL, Amazon RDS, Cloud SQL, Supabase, Neon - and EF Core's Postgres provider (Npgsql) is feature-rich. SQL Server is excellent inside Microsoft shops but harder to justify outside. The architectural advice in this series applies to both engines because EF Core abstracts most of the surface.
When does a document store like MongoDB or Cosmos DB win?
Three cases: (1) the document is genuinely tree-shaped - a CMS page with nested blocks - and you read it whole; (2) the schema varies by tenant or feature flag; (3) you need horizontal partitioning across regions and Postgres's logical replication is not enough. Otherwise the relational model still wins because joins are cheap when the data is small.
Should I add a read replica before sharding?
Almost always. Read replicas are cheap and transparent - one connection-string change in EF Core. Sharding is invasive: every query must include a shard key, every transaction is local-only, every cross-shard query goes to the application layer. The ladder is single-node Postgres → Postgres + read replica → Postgres + multiple read replicas → sharded Postgres or Citus → NoSQL.
How do I tell if EF Core is the bottleneck?
Run dotnet-counters against Microsoft.EntityFrameworkCore and look at total-commands-executed-rate, total-queries-rate, and saved-changes durations. If query latency p99 is acceptable but the wall-clock time is dominated by .NET-side serialisation, switch to compiled queries or AsNoTracking. If query latency itself is too high, it is a database issue and a cache or schema fix - not an EF Core fix.