<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Haik</title>
    <description>The latest articles on DEV Community by Haik (@haikasatryan).</description>
    <link>https://dev.to/haikasatryan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3874790%2F62759872-e001-47ff-bdac-2de73888fb40.png</url>
      <title>DEV Community: Haik</title>
      <link>https://dev.to/haikasatryan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/haikasatryan"/>
    <language>en</language>
    <item>
      <title>I'm a .NET Developer. I Spent Two Months Benchmarking Go vs C# and I Didn't Like the Answer.</title>
      <dc:creator>Haik</dc:creator>
      <pubDate>Thu, 20 Aug 2026 11:22:10 +0000</pubDate>
      <link>https://dev.to/haikasatryan/im-a-net-developer-i-spent-two-months-benchmarking-go-vs-c-and-i-didnt-like-the-answer-31fm</link>
      <guid>https://dev.to/haikasatryan/im-a-net-developer-i-spent-two-months-benchmarking-go-vs-c-and-i-didnt-like-the-answer-31fm</guid>
      <description>&lt;p&gt;I write C#. I have written C# for years. I like EF Core, I like LINQ, and every time somebody told me Go was faster, I opened the same leaderboard everyone opens and pointed at the row where ASP.NET Core sits on top.&lt;/p&gt;

&lt;p&gt;Then I stopped arguing and measured it.&lt;/p&gt;

&lt;p&gt;I built the same invoicing API twice, once in Go and once in C#. Same database, same cache, same six CPU cores, same latency promise. Two months of evenings and weekends went into building the two services, the spec they both implement and the harness that measures them; the measuring itself was four days, 618 windows and 358 million requests. Every CPU and memory figure is read straight out of the Linux kernel's cgroup accounting, so neither runtime got to grade its own homework.&lt;/p&gt;

&lt;p&gt;The answer was not the one I wanted, and it was not close. I'll get to the numbers. First I have to show you why the leaderboard everybody quotes points the other way, because until that makes sense my numbers just look like one guy on the internet disagreeing with TechEmpower.&lt;/p&gt;

&lt;p&gt;Everything is open source, so you can check the work as you go: &lt;strong&gt;&lt;a href="https://github.com/HaikAsatryan/go-vs-dotnet-benchmark" rel="noopener noreferrer"&gt;github.com/HaikAsatryan/go-vs-dotnet-benchmark&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: why the leaderboard says the opposite
&lt;/h2&gt;

&lt;p&gt;Someone tells you Go is faster. They always do. Ask &lt;em&gt;how much&lt;/em&gt;, or faster &lt;em&gt;at what&lt;/em&gt;, and the conversation gets vague fast. So let's start with the numbers everybody links.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.techempower.com/benchmarks/" rel="noopener noreferrer"&gt;TechEmpower&lt;/a&gt; was the reference for a decade. Its &lt;strong&gt;Fortunes&lt;/strong&gt; test is the one worth quoting, because it is the only popular one that touches a database, an ORM, sorting, templating and HTML escaping instead of just echoing a string back. Round 23, February 2025:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;framework&lt;/th&gt;
&lt;th&gt;language&lt;/th&gt;
&lt;th&gt;Fortunes req/s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ASP.NET Core&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;C#&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;609,966&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fiber&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;338,096&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actix&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;320,144&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spring&lt;/td&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;td&gt;243,639&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Express&lt;/td&gt;
&lt;td&gt;Node&lt;/td&gt;
&lt;td&gt;78,136&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Django&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;32,651&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Round 23 Fortunes, popular-framework slice, &lt;a href="https://dev.to/tuananhpham/popular-backend-frameworks-performance-benchmark-1bkh"&gt;as tabulated by Tuan Anh Pham&lt;/a&gt;. The full leaderboard has a long tail of C++ and Rust exotica nobody ships either.&lt;/p&gt;

&lt;p&gt;C# is not just ahead of Go there. It is &lt;strong&gt;1.8x ahead&lt;/strong&gt;, on the most database-shaped test in the suite, and it has looked roughly like this for years.&lt;/p&gt;

&lt;p&gt;So it should be settled, right? C# is faster, has a richer ecosystem, better tooling, a nicer type system and a bigger hiring pool. Everyone picking Go for backend services is being sentimental.&lt;/p&gt;

&lt;h3&gt;
  
  
  Then why is Docker written in Go?
&lt;/h3&gt;

&lt;p&gt;And Kubernetes. And Terraform, Prometheus, etcd, containerd, Grafana, InfluxDB, Traefik, Caddy, CockroachDB, Vault, Consul, Cilium.&lt;/p&gt;

&lt;p&gt;That is not one team's taste. That is essentially the entire infrastructure layer of the modern internet, built by people who measure things for a living, choosing the language the leaderboard calls slower.&lt;/p&gt;

&lt;p&gt;"It's history" only gets you so far. Docker started in 2013, when .NET did not run on Linux. Fine. But .NET Core landed in 2016 and has been genuinely cross-platform for a decade, and in those ten years the infrastructure world kept picking Go anyway.&lt;/p&gt;

&lt;p&gt;And then there is the one that should really bother a C# person.&lt;/p&gt;

&lt;p&gt;In 2025, Microsoft ported the TypeScript compiler to native code, chasing roughly a 10x speedup. They wrote it in &lt;strong&gt;Go&lt;/strong&gt;. The lead architect of TypeScript is &lt;strong&gt;Anders Hejlsberg&lt;/strong&gt;, the man who created C#, and Turbo Pascal and Delphi before that. If anybody alive had both the ability and the motive to make C# the answer, it was him. He is also, by any measure, one of the best language designers who has ever lived, which is exactly why his choice is worth more than my benchmark.&lt;/p&gt;

&lt;p&gt;His &lt;a href="https://devclass.com/2025/03/12/typescript-compiler-ported-to-native-code-c-faithful-ask-why-go-was-used/" rel="noopener noreferrer"&gt;stated reasoning&lt;/a&gt;: Go was "the lowest-level language available that still has garbage collection," giving "optimized native code for all platforms" and "great control over data layout." On C#, despite everything AOT has added, he noted it is "bytecode-first," is not universally available across platforms, "doesn't have a decade of hardening" in that mode, and "was not engineered that way."&lt;/p&gt;

&lt;h3&gt;
  
  
  The three reasons people give, and why none of them is on your bill
&lt;/h3&gt;

&lt;p&gt;Push on "Go is faster" and you usually get some mix of three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It starts instantly.&lt;/strong&gt; Milliseconds. No JIT warm-up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It ships as one small static binary.&lt;/strong&gt; No runtime to install, tiny container image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It sits on almost no RAM when idle.&lt;/strong&gt; Ten megabytes and a nap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three are true. Go wins all three, and it isn't close.&lt;/p&gt;

&lt;p&gt;Now look at what they have in common: &lt;strong&gt;none of them is on your bill.&lt;/strong&gt; A backend service starts once and then runs for three weeks. Startup time is a number you watch during a deploy and then never think about again. Image size is a one-time pull. Idle RAM is what your service uses in the state where it is doing nothing, which is not the state you are paying for.&lt;/p&gt;

&lt;p&gt;What you actually pay for is CPU-seconds and gigabyte-hours &lt;em&gt;while it is serving traffic&lt;/em&gt;, and how much traffic one box takes before your latency promise breaks. And on those two, the leaderboard says C# wins.&lt;/p&gt;

&lt;h3&gt;
  
  
  The catch: which row are you quoting?
&lt;/h3&gt;

&lt;p&gt;TechEmpower's results page has a &lt;strong&gt;Classification&lt;/strong&gt; filter: &lt;em&gt;Platform&lt;/em&gt;, &lt;em&gt;Micro&lt;/em&gt;, &lt;em&gt;Fullstack&lt;/em&gt;. A &lt;strong&gt;Fullstack&lt;/strong&gt; entry uses the framework the way you would, with the real router and the real ORM. A &lt;strong&gt;Platform&lt;/strong&gt; entry is allowed to bypass all of that and write low-level code straight against the runtime.&lt;/p&gt;

&lt;p&gt;.NET's headline entry, &lt;code&gt;aspcore&lt;/code&gt;, is Platform class: no MVC, no ORM, raw database connectivity. It is beautiful code, and it is not an application. It shows how fast the runtime goes once you take the framework out of it.&lt;/p&gt;

&lt;p&gt;Compare the &lt;em&gt;Fullstack&lt;/em&gt; rows instead and the order flips. Dustin Moris Gorski &lt;a href="https://dusted.codes/how-fast-is-really-aspnet-core" rel="noopener noreferrer"&gt;ran exactly that comparison on Round 21&lt;/a&gt;: ASP.NET Core MVC with real templating managed about &lt;strong&gt;184k req/s&lt;/strong&gt; on Fortunes, while a Go entry did about &lt;strong&gt;381k&lt;/strong&gt;. (Different round, different hardware, so don't line those up against the table above. The ordering is the point.) Same benchmark, same test, opposite answer, depending entirely on which row you quote.&lt;/p&gt;

&lt;p&gt;TechEmpower &lt;a href="https://github.com/TechEmpower/FrameworkBenchmarks/issues/10932" rel="noopener noreferrer"&gt;shut down in March 2026&lt;/a&gt;. Its announcement gives no reasons, so I won't invent any, but the &lt;a href="https://dev.to/kaliumhexacyanoferrat/techempower-framework-benchmarks-are-now-archived-whats-next-3l0a"&gt;widely shared community read&lt;/a&gt; was that those low-level tricks had turned it into "more a socket benchmark than actual real-life implementations used by end-users."&lt;/p&gt;

&lt;p&gt;It measured what it measured, extremely well: the &lt;strong&gt;ceiling&lt;/strong&gt; of a runtime when an expert strips the framework away. .NET's ceiling really is spectacular. It just is not the thing you deploy.&lt;/p&gt;

&lt;p&gt;So I measured the thing you deploy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: the benchmark I ran
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What I built
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Ledgerline&lt;/strong&gt; is a small invoicing API. Create an invoice (a real multi-statement database transaction), fetch an invoice (cache-aside through Redis), list a customer's invoices, price a quote, and a PDF-stub endpoint.&lt;/p&gt;

&lt;p&gt;I wrote the spec once, &lt;strong&gt;froze it before measuring anything&lt;/strong&gt;, and implemented it twice the way a normal team would ship it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Go:&lt;/strong&gt; &lt;code&gt;net/http&lt;/code&gt;, &lt;code&gt;sqlc&lt;/code&gt; over &lt;code&gt;pgx&lt;/code&gt;, &lt;code&gt;go-redis&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C#:&lt;/strong&gt; Minimal API on .NET 10, EF Core, Npgsql, StackExchange.Redis.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No hand-rolled routers. No pre-built byte arrays. No leaderboard tricks. Boring code on both sides, the kind that shows up in a pull request without anyone commenting on it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnuplvgg59m8xkw9w9dba.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnuplvgg59m8xkw9w9dba.png" alt="The test rig: one box, one workload, two implementations of the same frozen spec" width="800" height="492"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hardware&lt;/td&gt;
&lt;td&gt;Ryzen 9 7950X, Fedora 43, tuned: CPU governor pinned, boost off, SMT siblings excluded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU budget&lt;/td&gt;
&lt;td&gt;service under test &lt;strong&gt;6 cores&lt;/strong&gt;, PostgreSQL 3, load generator 3, Redis 1, each in its own cgroup and cpuset&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go side&lt;/td&gt;
&lt;td&gt;Go 1.26.5, net/http, sqlc over pgx v5.10.0, go-redis 9.22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C# side&lt;/td&gt;
&lt;td&gt;.NET 10, Minimal API, EF Core 10.0.11, Npgsql 10.0.3, StackExchange.Redis 3.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared&lt;/td&gt;
&lt;td&gt;PostgreSQL 18.4, Redis 8, 24-connection pools on both sides, verified warm at 24/24 in every cell&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load&lt;/td&gt;
&lt;td&gt;vegeta 12.13, open loop, 120-second measured windows behind gated warm-ups&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Working set&lt;/td&gt;
&lt;td&gt;1,000,000 pre-generated requests drawn Zipf-hot from 5M seeded invoices. Measured Redis hit rate &lt;strong&gt;0.807&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The promise&lt;/td&gt;
&lt;td&gt;mixed-workload &lt;strong&gt;p99 at or under 20 ms&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Receipts&lt;/td&gt;
&lt;td&gt;618 measured windows, 358,673,386 requests, four days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things make those numbers mean something. If you're newer to this, these are the parts most blog benchmarks get wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Every CPU and memory figure comes from cgroup v2.&lt;/strong&gt; That's the kernel's own accounting for the container, not something the process said about itself. A runtime cannot flatter itself here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The load is open loop.&lt;/strong&gt; A closed-loop generator waits for a response before sending the next request, so when your server slows down, the generator politely slows down too and hides the failure. An open-loop generator keeps firing at the offered rate regardless. That's how real traffic behaves, and it's the only way to see a service actually fall over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The database gets its own cores, and it never broke.&lt;/strong&gt; Before any measured run I laddered PostgreSQL on its own 3 cores to &lt;strong&gt;3,715 req/s&lt;/strong&gt; of create-only traffic, the heaviest thing in the mix, and it was still keeping up when the ladder ran out. It never broke during the runs either. Fair warning on this one: my pre-registered headroom check compared that against a demand figure I estimated &lt;em&gt;before&lt;/em&gt; the ladder ran, and the runs ended up going faster than I planned for, so read the database as unfalsified rather than proven innocent. Both services faced the identical database, interleaved in time, so the comparison holds either way.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;p99 means:&lt;/strong&gt; sort every response time in the window, take the value 99% of them come in under. It's the slow-tail number. Averages hide pain; p99 doesn't.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 3: the results
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Result 1: Go did the same work for a third of the CPU
&lt;/h3&gt;

&lt;p&gt;Same rate, same work, same box:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpi7omgxtlz9skrm7fnwn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpi7omgxtlz9skrm7fnwn.png" alt="CPU-ms burned per request vs offered load: C# runs 3-5x higher than Go at every rate" width="800" height="465"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the ratios left to right, because this is the most practically useful thing in the post. &lt;strong&gt;The ratio is not a number, it is a curve.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Go's per-request cost is a flat line: &lt;strong&gt;7.4% coefficient of variation across 19 rungs spanning a 26x range of load&lt;/strong&gt;. It costs what it costs.&lt;/p&gt;

&lt;p&gt;C#'s cost falls by more than half as load rises, because tiered JIT, dynamic PGO and the runtime's fixed overheads amortise across more requests. That's why the gap is worst exactly where most services live. &lt;strong&gt;A .NET service cruising at 10% CPU is sitting at the expensive end of its own curve.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Across every rung of every configuration I ran, the range is &lt;strong&gt;2.64x to 4.85x&lt;/strong&gt;. If you want one number to plan with, use &lt;strong&gt;3x&lt;/strong&gt;, and expect worse than that if your traffic is modest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Result 2: 5x less memory, with one honest caveat
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2wgaigr89o0wst6aqa7h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2wgaigr89o0wst6aqa7h.png" alt="Memory: 32 MB vs 171 MB while serving, but only 1.15x apart on container peak" width="799" height="621"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The entire steady-state Go service, serving thousands of requests per second with a database and a cache client attached, fits in about 32 MB. That's a quip, not a claim, but it's a good one.&lt;/p&gt;

&lt;p&gt;And now the caveat, because I would rather you hear it from me than from the comments:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you size containers by the kernel's high-water mark over the container's whole life&lt;/strong&gt; (which includes page cache and warm-up), the two land within 15% of each other. Go's advantage is in &lt;em&gt;steady-state&lt;/em&gt; memory, which is what you feel when you pack services onto a node. It is not the number your orchestrator's memory limit has to be.&lt;/p&gt;

&lt;p&gt;There's one more place Go looked bad, and you should know about it. In a configuration where I told both runtimes that memory was free (&lt;code&gt;GOGC=off&lt;/code&gt; against .NET with dynamic heap adaptation disabled), &lt;strong&gt;Go ballooned to 6.6 GB while .NET held 212 MB.&lt;/strong&gt; Go's small footprint is a default, not a law of nature. That configuration failed my warm-up gates by construction, so I file it as posture rather than measurement, but it is the one result in this whole run where Go lost badly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Result 3: C# hits the wall first
&lt;/h3&gt;

&lt;p&gt;Cost is the bill. Capacity is the cliff.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcpjp4drr181dw4sylzae.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcpjp4drr181dw4sylzae.png" alt="p99 latency vs offered load: C# crosses the 20 ms promise at 10,426 req/s, Go never crosses" width="800" height="586"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The C# service held the 20 ms promise up to &lt;strong&gt;10,426 req/s&lt;/strong&gt;. Past that, the tail came apart the way tails do: not gracefully.&lt;/p&gt;

&lt;p&gt;Go did not break. Not there, not in any of the five configurations I ran. At the highest rate the run ever offered, &lt;strong&gt;13,312 req/s&lt;/strong&gt;, Go's warm windows averaged a &lt;strong&gt;16.3 ms p99&lt;/strong&gt; while C# was at 103 ms and long past its wall.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;offered rate&lt;/th&gt;
&lt;th&gt;Go p99&lt;/th&gt;
&lt;th&gt;C# p99&lt;/th&gt;
&lt;th&gt;20 ms promise&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;11,093 req/s&lt;/td&gt;
&lt;td&gt;15.2 ms&lt;/td&gt;
&lt;td&gt;23.7 ms (worst window 39.3)&lt;/td&gt;
&lt;td&gt;Go meets, C# misses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13,312 req/s&lt;/td&gt;
&lt;td&gt;16.3 ms (worst 20.4)&lt;/td&gt;
&lt;td&gt;103.1 ms*&lt;/td&gt;
&lt;td&gt;Go meets, C# is past its wall&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;* C# at 13,312 is past its own wall, and all ten of its windows were thrown out by my pre-registered gates: six for an error rate up to 2.5% (the limit is 0.1%) and four for delivering as little as 81.5% of the offered load. Read it as posture, not a measurement. This is what the far side of a wall looks like.&lt;/p&gt;

&lt;p&gt;So Go holds the promise to &lt;strong&gt;at least 1.28x&lt;/strong&gt; the rate C# does. I can't tell you the real multiple, because &lt;strong&gt;I never found Go's wall.&lt;/strong&gt; Four days of trying, and the honest answer is "further up than my load generator went."&lt;/p&gt;

&lt;h3&gt;
  
  
  "It's EF Core's fault." No, it isn't.
&lt;/h3&gt;

&lt;p&gt;Before anyone says it: yes, I checked. Four data layers, each a full multi-hour run on the identical workload.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3smpir75ee4d5qw35qhy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3smpir75ee4d5qw35qhy.png" alt="Knee by .NET data layer: EF Core, Dapper and raw ADO.NET all land near 10,300 req/s" width="799" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The three at the top are the same number. &lt;strong&gt;EF Core, Dapper and hand-written ADO.NET land within 2.5% of each other&lt;/strong&gt;, and EF costs about 20% more CPU per request than raw ADO at the same rate. You cannot rewrite your way out of the Go gap by dropping the ORM.&lt;/p&gt;

&lt;p&gt;The outlier at the bottom is EF Core on Npgsql's defaults. The fix is one connection-string setting, &lt;strong&gt;&lt;code&gt;Max Auto Prepare&lt;/code&gt;&lt;/strong&gt;, which pgx has on and Npgsql does not: turn it on and EF Core goes from 1,468 to 10,426 req/s. That is &lt;strong&gt;7x&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;But look at what the other two rows are telling you. Dapper and raw ADO.NET ran with that same setting at its default of &lt;em&gt;off&lt;/em&gt;, and they still reached 10,177 and 10,334. So this is not an Npgsql default that hurts every .NET data layer. It is specific to how EF Core issues its parameterised queries when they aren't prepared: hand-written SQL on the same driver, same default, doesn't care. Everything else in this post uses the fixed configuration, so the Go-vs-C# comparison is not quietly carrying that difference.&lt;/p&gt;

&lt;p&gt;Hold on to that number. It comes back in a minute.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I am &lt;em&gt;not&lt;/em&gt; claiming
&lt;/h2&gt;

&lt;p&gt;A benchmark that only tells you the good parts is marketing. Here's what this run does not support:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No Go knee.&lt;/strong&gt; Go never crossed 20 ms in any ladder I ran, so its wall is unmeasured. Every Go capacity number here is a floor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a single ratio.&lt;/strong&gt; Cost per request is a curve on both sides. Every ratio here is stamped with the rate it was measured at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;These knees are not precision instruments.&lt;/strong&gt; The crossing brackets carry as few as one or two measurement-grade repetitions against a pre-registered minimum of ten. The third digit of any knee is decoration. The ordering is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warm-window thinness is this run's biggest data-quality cost.&lt;/strong&gt; My warm-up gates (GC cadence steady, tail flat, pools warm) flagged &lt;strong&gt;59.5% of all 618 windows&lt;/strong&gt;, and they hit Go harder than C# (67% vs 52%) because Go's per-second p99 wanders more at high rates. Some Go roll-ups rest on one or two fully-warm windows. The counts ride along with every table in the repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No statistical significance on the paired latency deltas.&lt;/strong&gt; Only the low-rate configuration produced enough warm pairs to run the test; n was 2 and 3, and the intervals span zero. Direction and effect size only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TechEmpower is not wrong on its own terms.&lt;/strong&gt; It measures what it measures, extremely well. I'm claiming it doesn't measure your service, and that people quote it as though it does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not captured:&lt;/strong&gt; per-query PostgreSQL CPU attribution, GC pause histograms, and anything off this one box, one workload, one topology.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;And both of these are genuinely fast.&lt;/strong&gt; At 9,244 req/s, a rate most services will never see, Go's p99 was 2.4 ms and C#'s was 4.1 ms. The gap here is a bill, not a broken service.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hygiene, since a benchmark should show its receipts: 618 measured windows, 358,673,386 requests, &lt;strong&gt;seven HTTP 500s in total&lt;/strong&gt;, zero CPU-throttled windows, zero OOM kills, achieved rate equal to offered rate in every window I kept, and a cross-implementation equivalence suite passing 19 of 19 before the run started. There were also 906,235 connection-level failures with no HTTP response at all, 0.25% of the total, essentially all of them at rates past a service's wall. Those probes are excluded from every fit by a rule I wrote down before the run.&lt;/p&gt;




&lt;h2&gt;
  
  
  So what does this actually mean?
&lt;/h2&gt;

&lt;p&gt;Let me say the uncomfortable part plainly, because I'm the one who has to live with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the code people actually ship, Go beat C# on every performance axis I measured.&lt;/strong&gt; Not narrowly. Not in a lab. Not with tricks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~3x less CPU&lt;/strong&gt; per request at the same load, and up to &lt;strong&gt;4.85x&lt;/strong&gt; at low load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3x to 5x less&lt;/strong&gt; steady-state memory while serving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lower p99&lt;/strong&gt; at every confirm-stage operating point in the run. (On the exploratory ladder, where some rungs rest on a single window, Go's tail wanders and a few rungs land above C#. That noise is visible in the latency chart and it is why the analysis fits a monotone curve instead of trusting one point.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More traffic&lt;/strong&gt; before the latency promise broke, with no measured ceiling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the answer to "why is Docker written in Go?" It isn't that Go's runtime wins in a lab; .NET's ceiling is higher than Go's. It's that &lt;strong&gt;Go's floor&lt;/strong&gt; (the cost of ordinary code doing ordinary work) is about three times cheaper, its cost is flat and predictable at every load, and it doesn't have to be busy before it stops being expensive. That's the number that lands on an invoice every month, and it is what the infrastructure world has been telling you for a decade by choosing Go.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the next time someone links you TechEmpower:&lt;/strong&gt; that benchmark measured a runtime with the framework surgically removed by an expert. Nobody ships that code. It never measured what you deploy, and as of March 2026 it isn't running at all. Ask them which row they're quoting: Platform, or Fullstack?&lt;/p&gt;

&lt;p&gt;I'd rather have found the opposite. But the point of measuring is that you don't get to choose.&lt;/p&gt;




&lt;h2&gt;
  
  
  Now the part that actually matters
&lt;/h2&gt;

&lt;p&gt;Everything above is about &lt;strong&gt;speed and cost&lt;/strong&gt;. That's one axis. On most projects it is not the axis that decides anything, and I'd be doing you a disservice if I let a benchmark post end at "use Go."&lt;/p&gt;

&lt;p&gt;So here's the guide I'd actually give a junior engineer asking "which language should we use?"&lt;/p&gt;

&lt;p&gt;The one-sentence version: for a new product you can pick almost anything, including Python, which is around an order of magnitude slower than either language in this post and still runs Instagram. Ship first, measure later. The longer version is below.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo3l6oasidhu6ebo4abgv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo3l6oasidhu6ebo4abgv.png" alt="Quadrant: pick a language per service by domain complexity and compute bill" width="799" height="635"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The most important input is your team, not the language
&lt;/h3&gt;

&lt;p&gt;The first question is not "which language is fast." It's &lt;strong&gt;"which language can my team write well, and can I hire more of those people where I am?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A team fluent in C# will ship a better, safer, faster C# system than an unfamiliar Go one. Language performance is a constant factor. Team fluency is a multiplier on everything else: design quality, review quality, how fast bugs get caught, how long onboarding takes, whether you can replace someone who leaves.&lt;/p&gt;

&lt;p&gt;If you cannot hire it, it doesn't matter how fast it benchmarks.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Architecture beats language by an order of magnitude. I measured that by accident.
&lt;/h3&gt;

&lt;p&gt;Look at that data-layer chart again. &lt;strong&gt;One Npgsql connection-string setting moved C# from 1,468 to 10,426 req/s.&lt;/strong&gt; That's 7x, from one line of configuration, and it's more than twice the entire Go-vs-C# gap.&lt;/p&gt;

&lt;p&gt;Now think about the things that are far worse than a missing config flag:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an N+1 query in a hot path&lt;/li&gt;
&lt;li&gt;a missing index&lt;/li&gt;
&lt;li&gt;a chatty service boundary that turns one request into twelve&lt;/li&gt;
&lt;li&gt;no cache where a cache obviously belongs&lt;/li&gt;
&lt;li&gt;a synchronous call where a queue belonged&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those routinely costs 10x to 100x. &lt;strong&gt;Go with any of them loses to C# without them, and it isn't close.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You cannot out-language a bad design. You can very easily out-design a slow language.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. You don't pick a language once. You pick one per service.
&lt;/h3&gt;

&lt;p&gt;Go and C# in the same company is completely normal and not a smell. Different services have different shapes. Treating "our language" as a company-wide identity is how you end up writing a Kubernetes operator in C# and a claims-adjudication engine in Go, both for no reason.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Use each language for what it was built for
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Go&lt;/strong&gt; was designed at Google for infrastructure: fast builds, one static binary, cheap concurrency, and a deliberately small language that a large team reads consistently. That minimalism is a feature when the domain is thin and a tax when it's thick. Reach for Go when the surface area is small and the volume is high: proxies, gateways, agents, CLIs, schedulers, cloud-native tooling, high-throughput plumbing, anything that ships as one binary and gets deployed 500 times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;C# / .NET&lt;/strong&gt; was built for applications with deep business rules. When the hard part isn't throughput but modelling 200 rules that change every quarter, a rich type system, LINQ, a real ORM and a mature ecosystem for the boring things (background jobs, validation, mapping, reporting, integrations) are worth far more than 3x CPU. Billing, ERP, insurance, healthcare, e-commerce with real pricing and promotions: that's where I'd still pick C# knowing everything in this post. Writing that domain in Go is possible, and it is a lot more typing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"But Rust is faster than Go. Why isn't Docker in Rust?"&lt;/strong&gt; Because speed is not the only axis, which is the entire point of this post. Rust beats Go on CPU and memory and has no GC pauses at all, and it charges you for that: an ownership model your whole team has to learn, slower builds, a smaller hiring pool, and real friction with the cyclic, graph-shaped data structures that compilers and orchestrators are made of. Go is the point on that curve where you get most of the systems-language win for a fraction of the cognitive cost. The TypeScript team wrote down exactly this trade: &lt;a href="https://github.com/microsoft/typescript-go/discussions/411" rel="noopener noreferrer"&gt;a from-scratch Rust rewrite&lt;/a&gt; would have been a multi-year job producing something incompatible, while Go let them port the existing compiler largely mechanically. Plenty of infrastructure is written in Rust today, and when you genuinely need the last increment of performance and can pay for it, that is the right call. Most teams do not, and cannot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest caveat&lt;/strong&gt;, because absolutes are how you get torn apart in the comments: all of this is a centre of gravity, not a law. &lt;a href="https://www.theregister.com/2020/03/09/monzo_microservices/" rel="noopener noreferrer"&gt;Monzo runs a bank on roughly 1,600 Go microservices&lt;/a&gt;. That's an argument about how you &lt;em&gt;slice&lt;/em&gt; a system, not about whether Go can hold money. Their complexity lives in the topology; the counter-case is one thick domain model in one service.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Do the arithmetic before you care about 3x
&lt;/h3&gt;

&lt;p&gt;Paying 3x means a bill that could be \$100 is \$300 instead. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compute bill of &lt;strong&gt;\$300/month&lt;/strong&gt; on C#? Moving to Go saves you around &lt;strong&gt;\$200/month&lt;/strong&gt;. Nobody should re-architect for that.&lt;/li&gt;
&lt;li&gt;Compute bill of &lt;strong&gt;\$200,000/month&lt;/strong&gt; on C#? Moving to Go saves you around &lt;strong&gt;\$130,000/month&lt;/strong&gt;. That is somebody's full-time job, and then some.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Work out which one you are. Most people argue like the second and live like the first.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. When the bill does hit, you don't rewrite everything
&lt;/h3&gt;

&lt;p&gt;You profile, find the two or three services that dominate the invoice, and rewrite those. That's a sprint, not a year, and by then you'll have the revenue and the traffic data to do it properly.&lt;/p&gt;

&lt;p&gt;"We'll rewrite the hot paths later" is not technical debt. It's the correct plan.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick answers to the comments I'm going to get
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"But TechEmpower says C# is faster."&lt;/strong&gt;&lt;br&gt;
It measured a runtime with the framework stripped out by an expert. Nobody ships that. Ask which row is being quoted: Platform, or Fullstack?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What about Native AOT? Doesn't that close the gap?"&lt;/strong&gt;&lt;br&gt;
I didn't test it, so treat this as reasoning and not data. AOT buys you the three things I argued aren't on your bill: instant startup, a small self-contained binary, no JIT warm-up. Those are real wins, and they are exactly the Go advantages that never show up on a long-running service's invoice. What AOT does not obviously improve is steady-state CPU per request, and it gives up the JIT's dynamic PGO, which is part of why the C# curve in my chart gets cheaper as load rises. It could go either way and I'd want to measure before claiming a direction.&lt;/p&gt;

&lt;p&gt;The reason I spent none of those four days on it: AOT still doesn't cover a large part of the .NET ecosystem. Anything that leans on runtime reflection or dynamic code generation either needs rework or isn't supported, and that describes a lot of what makes .NET pleasant to write. Hejlsberg made the same point when explaining the TypeScript port: C# is still "bytecode-first," isn't available everywhere, and "doesn't have a decade of hardening" in AOT mode. So I genuinely don't know how much real-world adoption AOT has for services shaped like this one. If you've run this comparison, I want to see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"So we should migrate to Go?"&lt;/strong&gt;&lt;br&gt;
Only if your compute bill is genuinely large, your domain is genuinely thin, &lt;em&gt;and&lt;/em&gt; your team can write good Go. Two out of three is a no.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Isn't this just EF Core being slow?"&lt;/strong&gt;&lt;br&gt;
No. Dapper and hand-written ADO.NET land within 2.5% of EF Core's number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Does this mean C# is bad?"&lt;/strong&gt;&lt;br&gt;
No. Both services were fast. At 9,244 req/s, Go's p99 was 2.4 ms and C#'s was 4.1 ms. This is a bill, not a broken service. I'm still writing C# on Monday.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Your Go/C# code is unidiomatic."&lt;/strong&gt;&lt;br&gt;
Maybe! The spec, both implementations, the runner and every raw artifact are in the repo, and there's an issue template for exactly this. Send a PR, I'll re-run it.&lt;/p&gt;




&lt;p&gt;Go and C# are not rivals. They're tools with different centres of gravity, and plenty of good companies run both. This benchmark says Go's floor is about three times cheaper than C#'s. That's a true and useful fact, and it is nowhere near the most important input to your next architecture decision.&lt;/p&gt;

&lt;p&gt;Just don't take it from a leaderboard. Measure your own service, at your own rate, and read the accounting from the kernel rather than from a scoreboard.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Run &lt;code&gt;20260814T051533-fedora&lt;/code&gt;. Every number here was recomputed from the per-probe raw artifacts (vegeta summaries, cgroup deltas, warm-up gate records) rather than quoted from the pipeline's own report, and the two agree. The spec, both service implementations, the analysis pipeline, the figure-generation script and the full artifact tree are in the repo. TechEmpower figures are Round 23 (February 2025). If you find a hole, &lt;a href="https://github.com/HaikAsatryan/go-vs-dotnet-benchmark/issues" rel="noopener noreferrer"&gt;open an issue&lt;/a&gt; and I'll fix the post.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>csharp</category>
      <category>dotnet</category>
      <category>performance</category>
    </item>
    <item>
      <title>PostgreSQL Write Performance: What the Benchmarks Won't Tell You</title>
      <dc:creator>Haik</dc:creator>
      <pubDate>Sun, 12 Apr 2026 18:24:28 +0000</pubDate>
      <link>https://dev.to/haikasatryan/postgresql-write-performance-what-the-benchmarks-wont-tell-you-mm7</link>
      <guid>https://dev.to/haikasatryan/postgresql-write-performance-what-the-benchmarks-wont-tell-you-mm7</guid>
      <description>&lt;p&gt;I've spent years building financial systems on PostgreSQL. Payment processing, account management, transaction engines. The kind of systems where every write matters and you can't cut corners.&lt;/p&gt;

&lt;p&gt;A while ago, I kept running into the same story online: "Boost your PostgreSQL to 100,000 inserts per second." "Python + Postgres: 1 million requests per second." The numbers were everywhere. Conferences, blog posts, YouTube videos. So when one of our products, a well-architected .NET application with a properly configured PostgreSQL instance, barely hit 1,000 writes per second on hot paths, I thought we did something wrong.&lt;/p&gt;

&lt;p&gt;We didn't.&lt;/p&gt;

&lt;p&gt;I spent weeks digging into this. Reviewing our code, our PostgreSQL configuration, our infrastructure. Everything was solid. The architecture was clean. The queries were optimized. The connection pooling was in place. And then I started actually reading those benchmarks carefully. That's when I realized: most of them are, to put it politely, not representative of real-world workloads.&lt;/p&gt;

&lt;p&gt;This blog is what I found.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; On a 16-core Ryzen 9 with a top-tier NVMe and 62 GB RAM, a single PostgreSQL 18 node tops out at &lt;strong&gt;~1,875 realistic transactional writes per second&lt;/strong&gt; with full durability. In production with standard SSDs and more complex schemas, expect less. That's a long way from the 100,000 inserts/sec that synthetic benchmarks advertise. Full benchmark results and methodology below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/tt_TULMl92M"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Only Writes?
&lt;/h2&gt;

&lt;p&gt;Let me be upfront about the scope. This post is only about write performance. Not reads. Not bulk inserts. Single-row transactional writes: the kind that happen when a real user hits a real API endpoint and expects a real result.&lt;/p&gt;

&lt;p&gt;Why ignore reads? Because read performance on PostgreSQL is, relatively speaking, a solved problem. Not trivial, but well-documented and dramatically easier to scale. The toolkit is rich and the path is clear:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Indexing strategies&lt;/strong&gt; are mature. B-tree, GIN, GiST, partial indexes, covering indexes. There are dozens of excellent guides on this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read replicas&lt;/strong&gt; scale horizontally with minimal effort. You can set up a CQRS pattern with N replicas, and PostgreSQL handles replication natively.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partitioning&lt;/strong&gt; works well and keeps large tables manageable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL 18 introduced asynchronous I/O&lt;/strong&gt; for read operations, delivering significant performance improvements (up to 2-3x on cloud storage with higher latency, and measurable gains even on local SSDs). Worth noting: this async I/O applies only to read operations. Writes, including WAL flushing, remain synchronous. (&lt;a href="https://www.postgresql.org/docs/current/release-18.html" rel="noopener noreferrer"&gt;PostgreSQL 18 Release Notes&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PgBouncer&lt;/strong&gt; and connection pooling eliminate connection overhead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared buffers and OS page cache&lt;/strong&gt; can serve most read workloads entirely from RAM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To put this in perspective: OpenAI runs a single-primary PostgreSQL deployment with nearly 50 read replicas, serving millions of queries per second for 800 million ChatGPT users. Their read scaling works. Their bottleneck? Writes. They directed new write-heavy workloads to Azure Cosmos DB because PostgreSQL's single-node write architecture has real limits. (&lt;a href="https://openai.com/index/scaling-postgresql/" rel="noopener noreferrer"&gt;OpenAI: Scaling PostgreSQL&lt;/a&gt;. Side note: this post is well worth reading in full. It's one of the clearest and most insightful pieces on real-world database scaling I've come across.)&lt;/p&gt;

&lt;p&gt;The point is: if you need to scale reads, you have options. Lots of them. If you need to scale writes, you're entering a fundamentally different territory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Bulk Writes?
&lt;/h2&gt;

&lt;p&gt;Bulk inserts are a different game. PostgreSQL's &lt;code&gt;COPY&lt;/code&gt; command, combined with tools like Kafka for batching, can push hundreds of thousands or even millions of rows per second. Companies do this all the time for analytics pipelines, event ingestion, and log processing. There are plenty of good resources on bulk write optimization.&lt;/p&gt;

&lt;p&gt;But that's not what this post is about.&lt;/p&gt;

&lt;p&gt;Think about what happens when a customer taps "Pay" on their phone. That's a single API call. A single database transaction. It reads the account balance, checks constraints, inserts a transaction record, updates the balance, and commits. There's locking involved. There are indexes to maintain. There's a WAL (Write-Ahead Log) entry that must be flushed to disk before the commit is acknowledged.&lt;/p&gt;

&lt;p&gt;You can't batch this. You can't tell the customer "hold on, we'll process your payment in 200 milliseconds when we have 500 other payments ready to go."&lt;/p&gt;

&lt;p&gt;This pattern applies to every system where transactional integrity matters on a per-request basis: payment processors, banking platforms, healthcare record systems, e-commerce order processing, booking systems. Any application where a single user expects a single, atomic result from a single action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Most PostgreSQL Write Benchmarks Are Misleading
&lt;/h2&gt;

&lt;p&gt;If you've searched for "PostgreSQL write performance" or "PostgreSQL inserts per second," you've seen impressive numbers. 50,000 inserts per second. 100,000. Even higher.&lt;/p&gt;

&lt;p&gt;Here's what those benchmarks typically do that your production system does not:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No indexes.&lt;/strong&gt; The test table has a primary key and nothing else. In production, your orders table has indexes on customer_id, created_at, status, maybe a composite index or two, possibly a GIN index on a JSONB column. Every index you add is a separate B-tree that PostgreSQL must update on every single write. A table with 8 indexes requires nine separate write operations per insert (one for the heap row and one for each index entry), along with corresponding WAL records for each. The overhead scales linearly with index count and adds up fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;fsync turned off.&lt;/strong&gt; PostgreSQL's &lt;code&gt;fsync&lt;/code&gt; setting controls whether the WAL is actually flushed to stable storage. With &lt;code&gt;fsync=off&lt;/code&gt;, PostgreSQL never asks the OS to flush writes to disk. It's dramatically faster, and catastrophically dangerous. If the server crashes, you don't just lose recent data. The database can end up in an &lt;strong&gt;unrecoverable, corrupted state&lt;/strong&gt; because partially-written pages go undetected. Every serious deployment runs with &lt;code&gt;fsync=on&lt;/code&gt;. No exceptions. (&lt;a href="https://www.postgresql.org/docs/current/wal-configuration.html" rel="noopener noreferrer"&gt;PostgreSQL WAL Configuration&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;synchronous_commit turned off.&lt;/strong&gt; This one is more nuanced, and often conflated with &lt;code&gt;fsync=off&lt;/code&gt;, but they're fundamentally different settings with fundamentally different risk profiles. With &lt;code&gt;synchronous_commit=off&lt;/code&gt;, PostgreSQL still writes WAL records, but doesn't wait for the disk flush before acknowledging the commit. If the server crashes, you might lose the last few hundred milliseconds of committed transactions, but the database recovers to a &lt;strong&gt;consistent state&lt;/strong&gt; via WAL replay. PostgreSQL's own documentation explicitly states this "does not create any risk of database inconsistency." Using &lt;code&gt;synchronous_commit=off&lt;/code&gt; per-transaction for non-critical writes (logging, metrics, notifications) is a legitimate, documented production pattern. But when benchmarks use it globally without disclosure and then claim "50,000 writes per second", that's a number your production system with &lt;code&gt;synchronous_commit=on&lt;/code&gt; will never match, and it's misleading to present it without context. Our benchmark tests both settings to show the actual gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unlogged tables.&lt;/strong&gt; PostgreSQL supports tables that skip WAL entirely. Faster? Absolutely. Production-ready for financial data? Absolutely not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No real business logic.&lt;/strong&gt; Production writes aren't just INSERT statements. They involve &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; locks, constraint checks, foreign key validations, and often multiple statements within a single transaction. The benchmark inserts a row into an empty table and calls it done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No concurrency contention.&lt;/strong&gt; Real systems have hundreds of connections competing for the same rows, the same indexes, and the same WAL buffer. Benchmarks typically measure throughput under ideal conditions with no lock contention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No retries, no error handling.&lt;/strong&gt; In production, serialization failures happen. Deadlocks happen. Your application retries, which means the effective throughput is lower than the raw insert rate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Just use a message queue."&lt;/strong&gt; This is the most common response when someone brings up write performance concerns. RabbitMQ, Kafka, SQS, pick your flavor. The idea is simple: accept the request, drop it onto a queue, return 202 Accepted, and let a background worker handle the heavy write later.&lt;/p&gt;

&lt;p&gt;And to be clear: this is a great pattern. We use it. You can make the initial synchronous write much cheaper (a lightweight "pending" record with fewer indexes, or an append to an event log) and defer the expensive multi-table transactional work to a background consumer. For periodic traffic spikes, it's brilliant: even if the consumer falls behind, it catches up during quieter periods, and retries handle transient failures gracefully.&lt;/p&gt;

&lt;p&gt;But it does not raise the database's sustained write throughput.&lt;/p&gt;

&lt;p&gt;Here's why. Even with the cheapest possible initial write, the background worker still writes back to the same PostgreSQL instance: status updates, ledger entries, audit logs. You haven't removed writes from the database; you've spread them across time. Instead of one transaction doing everything, you now have the initial write, the queue acknowledgment (if durable), and one or more background completion writes. The total write count often goes up, not down.&lt;/p&gt;

&lt;p&gt;For spikes, that's fine. The queue absorbs the burst and the database catches up. For sustained load at or near the ceiling, there's no catching up. The writes per second flowing into PostgreSQL are the writes per second flowing into PostgreSQL, whether they arrive from an API handler or a queue consumer. Async processing is an excellent architectural pattern for latency and resilience, but the write ceiling is still the write ceiling. The bottleneck just moves to a less visible place.&lt;/p&gt;

&lt;p&gt;This isn't a minor difference. The gap between a synthetic benchmark and real-world write performance can easily be 10x or more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting "Writes Per Second" in Context
&lt;/h2&gt;

&lt;p&gt;Before we look at actual numbers, let's calibrate what "writes per second" means in real business terms.&lt;/p&gt;

&lt;p&gt;1,000 sustained writes per second sounds small if you're used to thinking in terms of HTTP requests per second. But consider what it means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1,000 writes/sec = 86.4 million writes per day&lt;/li&gt;
&lt;li&gt;500 writes/sec = 43.2 million writes per day&lt;/li&gt;
&lt;li&gt;100 writes/sec = 8.6 million writes per day&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stripe, one of the largest payment processors in the world, processes billions of API requests, but their actual payment transaction rate is a fraction of that. Most mid-size fintech companies never sustain more than a few hundred transactional writes per second.&lt;/p&gt;

&lt;p&gt;Let me bring this closer to earth. I work in Armenia, a country of about 3 million people. The largest banks here sustain around 3 transactional inserts per second on their core tables on average. During peak hours, they might spike to around 100 inserts per second for brief periods. The entire national banking system doesn't come close to 500 sustained writes per second.&lt;/p&gt;

&lt;p&gt;I once encountered a fintech here that was reporting roughly 1,000 inserts per second on a hot path. Impressive, until I looked at their architecture. They were running fast inserts in a loop within their application layer, not genuine one-user-one-request transactions. The system design was wrong. Once you filter out that kind of architectural mistake and look only at genuine single-request transactional writes, the numbers are dramatically lower than what people imagine.&lt;/p&gt;

&lt;p&gt;When someone tells you their PostgreSQL setup handles "only" 1,000 genuine transactional writes per second, they're describing a system that could serve the transactional needs of a small country. The companies that genuinely need 10,000+ transactional writes per second on a single PostgreSQL instance are rare. And if you're at that scale, you're almost certainly a large enough operation that sharding is already on your roadmap, and you have the engineering team to handle it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Benchmark
&lt;/h2&gt;

&lt;p&gt;Here's where I stop talking and show data.&lt;/p&gt;

&lt;p&gt;I built a public benchmark designed to simulate real-world write patterns, not synthetic ones. You can find it here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/HaikAsatryan/pg-bench-real" rel="noopener noreferrer"&gt;github.com/HaikAsatryan/pg-bench-real&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Hardware
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Specification&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;AMD Ryzen 9 7950X, 16 cores / 32 threads @ 4.5 GHz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM&lt;/td&gt;
&lt;td&gt;62 GB DDR5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;WD_BLACK SN850X 2 TB NVMe (PCIe 4.0, ~7,300 MB/s seq read, ~6,600 MB/s seq write)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OS&lt;/td&gt;
&lt;td&gt;Fedora 43 (kernel 6.19)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;18.3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is a strong machine, better than most production database servers. Keep that in mind: these numbers are likely &lt;strong&gt;optimistic&lt;/strong&gt; compared to your production environment, which probably runs on cloud VMs with standard SSDs, shared resources, and network-attached storage.&lt;/p&gt;

&lt;h3&gt;
  
  
  PostgreSQL Configuration
&lt;/h3&gt;

&lt;p&gt;Auto-tuned from hardware, production-safe settings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fsync&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;on&lt;/code&gt; (always, non-negotiable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;synchronous_commit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;on&lt;/code&gt; (server default; benchmark also tests &lt;code&gt;off&lt;/code&gt; per-transaction)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shared_buffers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;15 GB&lt;/code&gt; (25% of RAM)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;effective_cache_size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;46 GB&lt;/code&gt; (75% of RAM)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;work_mem&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;64 MB&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wal_buffers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;64 MB&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;max_wal_size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;4 GB&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;max_connections&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;300&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;effective_io_concurrency&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;200&lt;/code&gt; (NVMe)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;random_page_cost&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1.1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;huge_pages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;try&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;checkpoint_completion_target&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.9&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  What We Tested
&lt;/h3&gt;

&lt;p&gt;Two transaction shapes that model real enterprise write patterns:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Standard OLTP Write&lt;/strong&gt; ("Record a new transaction"):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wallets&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;    &lt;span class="c1"&gt;-- account check&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;wallet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;  &lt;span class="c1"&gt;-- rate/fraud check&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;incl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt;  &lt;span class="c1"&gt;-- the write&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt;                    &lt;span class="c1"&gt;-- compliance trail&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;4 statements per transaction. 2 reads + 2 writes across 2 tables. This is what a typical API endpoint does when recording a payment, order, or event. Not every API write includes a fraud-check scan, but most production transactions involve at least one read before writing. We included it because skipping all reads is exactly the shortcut that makes synthetic benchmarks unrealistic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Banking Transfer&lt;/strong&gt; ("Move money between two accounts"):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wallets&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;  &lt;span class="c1"&gt;-- lock source&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wallets&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;  &lt;span class="c1"&gt;-- lock destination&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;wallets&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="c1"&gt;-- debit&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;wallets&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="c1"&gt;-- credit&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;incl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt;   &lt;span class="c1"&gt;-- record&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt;                     &lt;span class="c1"&gt;-- compliance&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;6 statements per transaction. 2 row locks held simultaneously across 5 statements. Canonical deadlock-avoidance ordering (always lock lower ID first). This is what creates real contention under load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema:&lt;/strong&gt; 12-column &lt;code&gt;transactions&lt;/code&gt; table (including a jsonb metadata column) with timestamptz, numeric(18,2), varchar, and text fields. 5 B-tree indexes. Monthly range partitioning by &lt;code&gt;created_at&lt;/code&gt;. 10,000-row &lt;code&gt;wallets&lt;/code&gt; table. &lt;code&gt;audit_log&lt;/code&gt; table with 2 indexes. 50 million rows pre-filled to push indexes beyond shared_buffers into the I/O-bound regime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Access pattern:&lt;/strong&gt; Zipfian distribution (alpha=1.0), where the top 1-2% of wallets absorb ~20% of traffic. This is what creates real contention. Uniform random access hides the single-node bottleneck we're trying to measure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeouts:&lt;/strong&gt; &lt;code&gt;statement_timeout=5s&lt;/code&gt;, &lt;code&gt;lock_timeout=1s&lt;/code&gt;, &lt;code&gt;idle_in_transaction_session_timeout=10s&lt;/code&gt;. Production-realistic limits. A transaction that can't finish under these is counted as a rejection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connection pool:&lt;/strong&gt; 50 connections (matching typical AddDbContextPool / HikariCP / psycopg-pool sizes).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Durability:&lt;/strong&gt; Tested with both &lt;code&gt;synchronous_commit=on&lt;/code&gt; (full durability, production default) and &lt;code&gt;synchronous_commit=off&lt;/code&gt; (no fsync wait) to show the exact performance gap that benchmarks hide when they don't disclose this setting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Driver:&lt;/strong&gt; Open-loop with Poisson arrivals. The driver produces requests at a fixed target rate regardless of how fast the DB drains them. When the DB can't keep up, requests queue and eventually time out. This is exactly what happens in production when your API gets more traffic than your database can handle. An adaptive binary search finds the rate where failures start.&lt;/p&gt;

&lt;h3&gt;
  
  
  Results
&lt;/h3&gt;

&lt;p&gt;Each probe ran for 7 minutes (spanning a full PostgreSQL checkpoint cycle) with 30 seconds of warmup discarded. The adaptive binary search converged to +/-6-9% brackets in 7 probes per sweep.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fahlp19bufkxlxat5l8zd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fahlp19bufkxlxat5l8zd.png" alt="PostgreSQL write ceiling comparison across four configurations" width="800" height="476"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Standard OLTP Write (synchronous_commit = on)
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;Ceiling: ~1,875 rps&lt;/strong&gt; (knee bracket: 1,875-2,046 rps)&lt;/p&gt;

&lt;p&gt;At the ceiling, the system sustained 1,875 committed transactions per second with zero errors and a P99 latency of 58 ms. Push it to 2,046 rps and errors jumped to 9%, all from pool wait timeouts. The database didn't crash or deadlock; it simply couldn't drain its connection pool fast enough.&lt;/p&gt;

&lt;p&gt;At the healthy ceiling: avg latency 9.2 ms, P50 3.1 ms, P99 58 ms. Over the 7-minute measurement window, 787,531 transactions committed successfully.&lt;/p&gt;

&lt;h4&gt;
  
  
  Banking Transfer (synchronous_commit = on)
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;Ceiling: ~2,390 rps&lt;/strong&gt; (knee bracket: 2,390-2,562 rps)&lt;/p&gt;

&lt;p&gt;The banking transfer, despite holding two row locks per transaction, achieved a &lt;em&gt;higher&lt;/em&gt; ceiling than the standard OLTP write. Why? The standard OLTP write includes a &lt;code&gt;SELECT count(*)&lt;/code&gt; fraud-check query on the 50M-row transactions table, an index scan that becomes expensive at scale. The banking transfer's reads hit only the 10,000-row wallets table, which fits entirely in shared buffers.&lt;/p&gt;

&lt;p&gt;At the healthy ceiling: avg latency 8.8 ms, P50 2.6 ms, P99 144 ms. Zero deadlocks, zero lock timeouts. The canonical lock ordering (always lock lower account ID first) worked exactly as designed.&lt;/p&gt;

&lt;h4&gt;
  
  
  The synchronous_commit Gap
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp9rzrrhaf4i63i6kr6na.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp9rzrrhaf4i63i6kr6na.png" alt="P99 latency vs target rate for both workloads" width="800" height="355"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We tested both &lt;code&gt;synchronous_commit=on&lt;/code&gt; and &lt;code&gt;off&lt;/code&gt; to measure the gap that benchmarks quietly exploit:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;sync_commit=on&lt;/th&gt;
&lt;th&gt;sync_commit=off&lt;/th&gt;
&lt;th&gt;Improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Standard OLTP Write&lt;/td&gt;
&lt;td&gt;~1,875 rps&lt;/td&gt;
&lt;td&gt;~1,875 rps&lt;/td&gt;
&lt;td&gt;~0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Banking Transfer&lt;/td&gt;
&lt;td&gt;~2,390 rps&lt;/td&gt;
&lt;td&gt;~2,734 rps&lt;/td&gt;
&lt;td&gt;~15%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The result surprised me. For the standard OLTP write, skipping the durability flush made &lt;strong&gt;no measurable difference&lt;/strong&gt; to the ceiling. The bottleneck wasn't WAL flush latency; it was the read queries and index maintenance within the transaction. When your transaction includes a range scan on a 50M-row table, shaving 0.5 ms off the commit doesn't move the needle.&lt;/p&gt;

&lt;p&gt;For the banking transfer, &lt;code&gt;synchronous_commit=off&lt;/code&gt; gave a 15% boost. Meaningful, but far from the 5-10x numbers that synthetic benchmarks imply. And remember: you're trading data durability for that 15%.&lt;/p&gt;

&lt;p&gt;The latency at sub-ceiling rates tells a clearer story. At 500 rps (well below saturation), banking transfers dropped from 1.7 ms average (sync=on) to 0.8 ms (sync=off), a 2x latency improvement. But the &lt;em&gt;ceiling&lt;/em&gt; barely moved. Faster commits let you do each transaction quicker, but the database still hits the same I/O wall.&lt;/p&gt;

&lt;h4&gt;
  
  
  How It Breaks
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2dgrgtlqzj7jbtqpf8ur.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2dgrgtlqzj7jbtqpf8ur.png" alt="Achieved vs target write rate, showing the saturation cliff" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This chart tells the whole story. The line follows the diagonal (achieved = target) until the ceiling, then flatlines. Push harder and the database doesn't crash, doesn't deadlock, doesn't corrupt anything. It simply can't drain its connection pool fast enough. Your requests time out waiting for a connection, and your API starts returning 503s.&lt;/p&gt;

&lt;p&gt;Every single failure at the knee was a &lt;strong&gt;pool wait timeout&lt;/strong&gt;. Not a single deadlock. Not a single statement timeout. PostgreSQL is well-behaved at its limits: it just has a finite throughput.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz61uny0odzlku4serr8d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz61uny0odzlku4serr8d.png" alt="Detailed metrics at the highest healthy rate per configuration" width="800" height="327"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Key Findings
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The realistic write ceiling on strong hardware is ~2,000 rps.&lt;/strong&gt; On a Ryzen 9 7950X with a top-tier NVMe drive and 62 GB of RAM (hardware better than most production database servers), the ceiling for a standard multi-statement OLTP write with 5 indexes, production timeouts, and full durability is approximately 1,875 writes per second. That's a long way from the 100,000 inserts/sec that synthetic benchmarks advertise.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Row contention doesn't kill you; large table scans do.&lt;/strong&gt; The banking transfer (with two row locks held simultaneously) was actually &lt;em&gt;faster&lt;/em&gt; than the standard OLTP write. The bottleneck was the fraud-check query scanning a 50M-row table, not the lock contention on 10,000 wallets.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;synchronous_commit=off gives at most 15%, not 10x.&lt;/strong&gt; Benchmarks that quietly use &lt;code&gt;synchronous_commit=off&lt;/code&gt; suggest massive performance gains. In reality, with a realistic workload, the improvement is modest. The WAL flush is only one component of the total transaction cost.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The failure mode is clean.&lt;/strong&gt; PostgreSQL doesn't crash, corrupt data, or spiral into deadlocks at the ceiling. It cleanly saturates the connection pool. Requests that can't get a connection within 1 second are rejected. This is a well-behaved system hitting a well-defined limit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;1,875 writes/sec = 162 million writes per day.&lt;/strong&gt; This is enough to serve the transactional needs of a mid-size country's banking system. Most applications will never come close to this limit.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The WAL Bottleneck: Why Writes Are Fundamentally Hard
&lt;/h2&gt;

&lt;p&gt;If you're wondering why write performance has a hard ceiling that read performance doesn't, the answer comes down to one thing: the Write-Ahead Log.&lt;/p&gt;

&lt;p&gt;Every write in PostgreSQL (every INSERT, UPDATE, and DELETE) must first be recorded in the WAL before it can be acknowledged. With &lt;code&gt;synchronous_commit=on&lt;/code&gt;, every transaction commit requires its WAL records to be physically flushed to disk.&lt;/p&gt;

&lt;p&gt;WAL &lt;em&gt;insertion&lt;/em&gt; itself has been concurrent since PostgreSQL 9.4: multiple backends can reserve LSNs and copy WAL records into shared buffers in parallel. But WAL &lt;em&gt;flushing&lt;/em&gt;, the actual &lt;code&gt;fsync()&lt;/code&gt; to disk, is serialized. That disk flush is the hard ceiling.&lt;/p&gt;

&lt;p&gt;Read replicas don't help here. Partitioning doesn't help. More RAM doesn't help. The WAL flush is a serialized disk I/O bottleneck by design. It's what guarantees your data is safe, and it's what limits how fast you can write.&lt;/p&gt;

&lt;p&gt;This is why horizontal write scaling (sharding) is fundamentally different from horizontal read scaling (replicas). Replicas are cheap: stream the WAL, apply it, serve reads. Sharding is expensive: split your data, split your application logic, handle distributed transactions, deal with cross-shard queries. Your code gets bigger, your architecture gets more complex, and your failure modes multiply.&lt;/p&gt;

&lt;p&gt;Tools like &lt;a href="https://www.citusdata.com/" rel="noopener noreferrer"&gt;Citus&lt;/a&gt; (now part of Azure) can help significantly. It extends PostgreSQL with distributed tables and handles a lot of the routing and query rewriting for you. But "significantly helps" is not the same as "makes it trivial." You still need to choose a distribution key, redesign queries that cross shard boundaries, handle distributed transactions carefully, and test thoroughly. I haven't personally needed to shard yet, so I can't speak from firsthand experience, but the engineers I've talked to who have describe it as a serious undertaking even with good tooling.&lt;/p&gt;

&lt;p&gt;For a deeper dive into WAL mechanics: &lt;a href="https://www.postgresql.org/docs/current/wal-intro.html" rel="noopener noreferrer"&gt;PostgreSQL Documentation: Write-Ahead Logging&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When Should You Start Worrying?
&lt;/h2&gt;

&lt;p&gt;The benchmark ceiling of ~1,875 rps was measured on strong hardware: a 16-core desktop CPU, a top-tier NVMe, and 62 GB of RAM. Your production environment is almost certainly worse.&lt;/p&gt;

&lt;p&gt;In my own experience, a well-architected .NET application running on production hardware with standard SSDs (not NVMe), a more complex schema (more columns, more indexes), and real network latency topped out at around &lt;strong&gt;~1,100 writes per second&lt;/strong&gt;. That's 40% lower than the benchmark, and our setup was well-optimized.&lt;/p&gt;

&lt;p&gt;Here's the framework I'd recommend:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;At 500+ sustained transactional writes per second&lt;/strong&gt;, start planning. Not panicking, planning. You have time, but the wall is real, and architectural changes (sharding, CQRS, write offloading) take months to design and years to fully implement. If your user base is growing, the time to start that conversation is &lt;em&gt;before&lt;/em&gt; you hit 1,000 rps, not after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At 1,000+ sustained writes per second&lt;/strong&gt;, you should already have a scaling strategy documented and partially validated. At this rate, a single bad query, a new index, or a traffic spike can push you into degradation territory. The margin for error shrinks fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At 1,500+ sustained writes per second&lt;/strong&gt; on production hardware, you're at the wall. Any growth from here requires horizontal scaling, and that's a fundamentally different kind of engineering effort.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The key word is &lt;em&gt;sustained&lt;/em&gt;. Brief spikes to 2,000 rps during a flash sale are different from a steady baseline of 1,500 rps growing 20% year-over-year. It's the growth curve that should drive your planning timeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;On a 16-core Ryzen 9 with a top-tier NVMe and 62 GB of RAM (hardware that would cost you $300-400/month in the cloud), a single PostgreSQL 18 node tops out at approximately &lt;strong&gt;1,875 realistic transactional writes per second&lt;/strong&gt;. With full durability, 5 indexes, multi-statement transactions, an audit trail, and production timeouts. On real production servers with standard SSDs, expect that number to be closer to 1,000-1,200.&lt;/p&gt;

&lt;p&gt;That's still 86-162 million writes per day. More than enough for the vast majority of applications on the planet.&lt;/p&gt;

&lt;p&gt;PostgreSQL is an exceptional database. This post isn't an argument against it. I've built my career on it, and I'd choose it again.&lt;/p&gt;

&lt;p&gt;But the internet is full of inflated write performance numbers that set unrealistic expectations. When your application hits 1,000 transactional writes per second and struggles, you're not doing something wrong. You're hitting a real, physical limitation that most benchmarks conveniently avoid. The time to prepare for that ceiling is when you're at 500, not when you're already drowning at 1,200.&lt;/p&gt;

&lt;p&gt;Know your real limits. Plan your architecture around honest numbers, not synthetic ones. And when someone tells you PostgreSQL can handle 100,000 writes per second, ask them: with how many indexes? With fsync on or off? With synchronous_commit on or off? With real transactions or bare inserts?&lt;/p&gt;

&lt;p&gt;The answers will tell you everything.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The benchmark code is open source. Run it on your own hardware and see what your PostgreSQL can actually do. The numbers might surprise you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>performance</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
