<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>README — databases</title>
<link>https://readme.news/tags/databases/</link>
<atom:link href="https://readme.news/tags/databases/feed.xml" rel="self" type="application/rss+xml"/>
<description>README pieces tagged databases.</description>
<language>en-us</language>
<lastBuildDate>Thu, 01 Oct 2026 13:20:31 +0000</lastBuildDate>
<item><title>Choosing an ID</title><link>https://readme.news/choosing-an-id/</link><guid isPermaLink="true">https://readme.news/choosing-an-id/</guid><pubDate>Fri, 18 Sep 2026 09:00:00 +0000</pubDate><description>Integers, UUIDs, ULIDs, or something with meaning. The decision is permanent and it leaks into everything.</description><content:encoded><![CDATA[<p>Primary key type is one of the few decisions that is genuinely hard to reverse. It propagates into every foreign key, every URL, every log line, every external integration, and every client that ever stored one.</p>
<p>Worth twenty minutes up front.</p>
<h2 id="the-options">the options<a class="anchor" href="#the-options" aria-label="link to this section">#</a></h2>
<p><strong>Auto-increment integer.</strong> Compact, fast, perfect index locality, human-readable in logs.</p>
<p>The problems are real. It leaks volume — <code>/orders/48213</code> tells a competitor how many orders you have taken. It requires a round trip to the database to learn the ID, which blocks client-side generation and batching. And it makes merging data from two systems a genuine ordeal, because both start at 1.</p>
<p><strong>UUIDv4.</strong> Random, globally unique, generatable anywhere without coordination, leaks nothing.</p>
<p>The cost is index behaviour. Random values scatter inserts across the entire B-tree, which destroys cache locality, inflates the index, and causes page splits on every insert. On a large, write-heavy table this is a genuine and measurable problem, not a theoretical one.</p>
<p><strong>UUIDv7.</strong> Time-ordered UUID: a millisecond timestamp prefix, then randomness. Keeps global uniqueness and client-side generation, restores insert locality because new rows land at the end of the index.</p>
<p>This is the default I would now recommend for most new systems. It fixes the one serious problem with v4 while keeping everything that made v4 attractive. Postgres has <code>uuidv7()</code> built in; most languages have a library.</p>
<p>The trade: it leaks creation time. Usually fine, occasionally not.</p>
<p><strong>ULID / KSUID and friends.</strong> Same idea as v7 — sortable, time-prefixed — with a more compact text encoding. Fine choices. UUIDv7 has the advantage of being a standard with native database support, which matters more than encoding length.</p>
<p><strong>Natural keys.</strong> Email, ISBN, SKU. Almost always a mistake as a primary key, because "naturally unique and never changes" turns out to be false: people change email addresses, and standards get revised. Use them as unique constraints, not as the identifier everything else points at.</p>
<h2 id="the-two-identifier-pattern">the two-identifier pattern<a class="anchor" href="#the-two-identifier-pattern" aria-label="link to this section">#</a></h2>
<p>Frequently the right answer is not to choose:</p>
<ul><li><strong>Internal key</strong>: <code>bigint</code>, auto-increment. Used for foreign keys and joins. Compact, fast, never exposed.</li><li><strong>External ID</strong>: UUIDv7 or a prefixed string. Used in URLs, APIs, logs, support conversations. Unique index on it.</li></ul>
<p>You get join performance and index locality internally, and no information leakage externally. The cost is one extra column and remembering which is which.</p>
<h2 id="prefix-your-external-ids">prefix your external IDs<a class="anchor" href="#prefix-your-external-ids" aria-label="link to this section">#</a></h2>
<p>If you expose identifiers, prefix them by type:</p>
<div class="code"><pre><code>cus_01J9F3K2M4N5P6Q7R8S9T0V1W2
ord_01J9F3K8X1Y2Z3A4B5C6D7E8F9</code></pre></div>
<p>This is a small thing with an outsized payoff:</p>
<ul><li>A support engineer can tell what an ID refers to without asking.</li><li>Passing a customer ID where an order ID belongs becomes detectable, and can be rejected at the API boundary rather than producing a confusing not-found.</li><li>Logs and error reports become self-describing.</li><li>You can rotate the encoding later without ambiguity.</li></ul>
<p>Several well-run APIs do this and it is consistently one of the things developers say they appreciate about them.</p>
<h2 id="the-practical-rules">the practical rules<a class="anchor" href="#the-practical-rules" aria-label="link to this section">#</a></h2>
<p><strong>Never expose auto-increment integers publicly.</strong> Volume leakage plus trivial enumeration.</p>
<p><strong>Do not use UUIDv4 as a clustered primary key</strong> on a table that will get large and write-heavy. If you already have, v7 for new tables and consider whether the old one needs a migration — usually it does not, but measure the index bloat before deciding.</p>
<p><strong>Store UUIDs as a native <code>uuid</code> type</strong>, not as text. Sixteen bytes versus thirty-six, and correct comparison semantics.</p>
<p><strong>Decide the case and format once</strong>, write it down, and validate at the boundary. Half your system accepting hyphenated lowercase and the other half accepting bare uppercase is a bug that surfaces years later in one integration.</p>
<p><strong>Never reuse an ID.</strong> Ever. Deleted means gone; the identifier is retired with it. Reuse turns a stale reference from a clean not-found into silent corruption pointing at the wrong record.</p>]]></content:encoded></item><item><title>The annual Postgres upgrade you keep deferring</title><link>https://readme.news/the-annual-postgres-upgrade-you-keep-deferring/</link><guid isPermaLink="true">https://readme.news/the-annual-postgres-upgrade-you-keep-deferring/</guid><pubDate>Thu, 03 Sep 2026 09:00:00 +0000</pubDate><description>A new major version every autumn, a five-year support window, and a migration that gets worse the longer you leave it.</description><content:encoded><![CDATA[<p>PostgreSQL ships a major version every autumn and has done for well over a decade. Each one is supported for five years. The arithmetic is simple and most organisations still get it wrong in the same direction.</p>
<h2 id="the-trap">the trap<a class="anchor" href="#the-trap" aria-label="link to this section">#</a></h2>
<p>Five years of support sounds generous, so the upgrade never makes the roadmap. Then one of two things happens:</p>
<p><strong>You reach end of life</strong> and have to do four or five major versions at once, under time pressure, with an accumulated set of behaviour changes nobody has read about.</p>
<p><strong>You quietly lose years of free improvement.</strong> Postgres releases carry substantial planner, vacuum and I/O work every single year. A database three versions behind is slower than it needs to be, on hardware you are already paying for.</p>
<p>The teams that find upgrades painful are always the ones who defer them. The teams that upgrade every year find it boring, because one version of behaviour change is a <a class="xref" href="/the-unreasonable-effectiveness-of-a-changelog/" title="The unreasonable effectiveness of a changelog">changelog</a> and four versions is a project.</p>
<h2 id="what-to-actually-do">what to actually do<a class="anchor" href="#what-to-actually-do" aria-label="link to this section">#</a></h2>
<p><strong>Read one document: the release notes' incompatibilities section.</strong> Not the feature list — the "Migration to Version N" section at the top. It is usually under a page and it is the only part that can hurt you.</p>
<p><strong>Test on a copy of production, at production size.</strong> Restore a real dump, run your application's test suite against it, and run your slowest queries. Planner changes are the main source of surprise, and they only show up with real statistics and real data volume.</p>
<p><strong>Check your extensions first.</strong> This is the most common blocker. Every extension must have a build for the target version, and if you depend on something niche, verify before you plan anything else.</p>
<p><strong>Use logical replication for anything where downtime matters.</strong> Replicate old to new, let it catch up, cut over. <code>pg_upgrade --link</code> is fast and requires a maintenance window; logical replication is more setup and needs seconds.</p>
<p><strong>Run <code>ANALYZE</code> immediately after.</strong> Modern <code>pg_upgrade</code> carries statistics across, which removed the classic post-upgrade outage where a statistics-free database planned everything terribly. Verify it happened rather than assuming.</p>
<h2 id="the-operational-habit-worth-adopting">the operational habit worth adopting<a class="anchor" href="#the-operational-habit-worth-adopting" aria-label="link to this section">#</a></h2>
<p>Put it in the calendar: <strong>evaluate in October, upgrade in the first quarter.</strong></p>
<p>That gives the release a few point versions to settle, keeps you at most one version behind, and turns the whole thing into a scheduled chore rather than an emergency. The same pattern works for any dependency with an annual cadence, and the calendar entry is what makes it happen — nobody ever gets around to it otherwise.</p>
<h2 id="the-version-everyone-is-actually-on">the version everyone is actually on<a class="anchor" href="#the-version-everyone-is-actually-on" aria-label="link to this section">#</a></h2>
<p>Run this and find out where you really are:</p>
<div class="code"><span class="code-lang">sql</span><pre><code class="lang-sql">SELECT version();
SELECT name, setting FROM pg_settings WHERE name IN
  ('server_version_num', 'shared_buffers', 'effective_cache_size',
   'work_mem', 'max_connections', 'effective_io_concurrency');</code></pre></div>
<p>While you are there: <code>max_connections</code> is almost certainly too high and <code>effective_io_concurrency</code> is almost certainly too low for SSD storage. Both defaults date from an era of different hardware, and both are one-line changes with measurable effects.</p>
<p>The upgrade is the boring part. The settings nobody has revisited since the database was provisioned are usually the bigger win.</p>]]></content:encoded></item><item><title>Connection pooling, explained properly</title><link>https://readme.news/connection-pooling-explained-properly/</link><guid isPermaLink="true">https://readme.news/connection-pooling-explained-properly/</guid><pubDate>Wed, 15 Jul 2026 09:00:00 +0000</pubDate><description>Why your database has 400 connections, why that is bad, and how to size a pool without guessing.</description><content:encoded><![CDATA[<p>Connection pool sizing is done by copying a number from a blog post, and the number is usually wrong in a specific and expensive way.</p>
<h2 id="why-connections-are-expensive">why connections are expensive<a class="anchor" href="#why-connections-are-expensive" aria-label="link to this section">#</a></h2>
<p>In Postgres specifically, each connection is a separate operating system process with its own memory. A few megabytes of baseline, plus work memory for sorting and hashing, plus its share of shared buffer access.</p>
<p>Four hundred connections means four hundred processes. The scheduler is context switching between them, they are contending for the same locks and buffers, and the memory is largely wasted because most of them are idle.</p>
<p>The counterintuitive result, which has been measured many times: <strong>throughput frequently goes down as connection count goes up, past a fairly low threshold.</strong></p>
<p>More connections does not mean more concurrency. It means more contention.</p>
<h2 id="the-actual-number">the actual number<a class="anchor" href="#the-actual-number" aria-label="link to this section">#</a></h2>
<p>A widely used starting formula:</p>
<div class="code"><pre><code>connections = (core_count × 2) + effective_spindle_count</code></pre></div>
<p>For an 8-core machine with SSD storage, that is somewhere around 16 to 20.</p>
<p>That number seems shockingly low to people running pools of 100 or more. It is correct, and the reasoning is straightforward: a query is either using CPU or waiting on I/O. You need enough connections to keep the cores busy and to have some work queued behind I/O waits. Past that, additional connections are queued at the database instead of queued in your pool, and queueing at the database is worse because it consumes resources.</p>
<p><strong>Test it.</strong> Take your load test, run it at pool sizes of 10, 20, 40, 80, and 160, and plot throughput and p99 latency. The curve rises, flattens, and then degrades. Most people are on the degrading side and have never looked.</p>
<h2 id="the-pooler-layers">the pooler layers<a class="anchor" href="#the-pooler-layers" aria-label="link to this section">#</a></h2>
<p>Three, and they do different things.</p>
<p><strong>Application-side pool.</strong> In-process, reuses connections across requests. Every ORM and database driver has one. This is the minimum.</p>
<p><strong>External pooler</strong> — PgBouncer, pgcat, or a cloud provider's equivalent. Sits between your application and the database and multiplexes many client connections onto few server connections.</p>
<p>This is what you need when you have many application instances. Twenty instances with a pool of 20 each is 400 connections to the database, even if each instance is mostly idle. A pooler collapses that to the number the database actually wants.</p>
<p><strong>The database's own limit.</strong> <code>max_connections</code>. Set it lower than you think — it is a safety valve, and setting it high does not make the database faster, it makes the failure mode worse.</p>
<h2 id="pooling-modes-and-the-one-that-bites">pooling modes, and the one that bites<a class="anchor" href="#pooling-modes-and-the-one-that-bites" aria-label="link to this section">#</a></h2>
<p>External poolers have modes and choosing wrong causes subtle correctness bugs.</p>
<p><strong>Session pooling.</strong> A client gets a server connection for the duration of its session. Safe, and provides little multiplexing benefit.</p>
<p><strong>Transaction pooling.</strong> A server connection is assigned per transaction and returned after commit. This is where the big multiplexing win is, and it is what most people want.</p>
<p><strong>The catch:</strong> anything that depends on session state breaks.</p>
<ul><li>Prepared statements (unless the pooler supports them explicitly, which newer ones do)</li><li><code>SET</code> at the session level</li><li>Session-level advisory locks</li><li><code>LISTEN</code>/<code>NOTIFY</code></li><li>Temporary tables</li><li><code>WITH HOLD</code> cursors</li></ul>
<p>If your ORM uses server-side prepared statements by default — many do — you must either disable them or use a pooler that handles them. This is the single most common transaction-pooling problem and it manifests as intermittent errors under load, which is a miserable thing to debug.</p>
<p><strong>Statement pooling.</strong> A connection per statement. Maximum multiplexing, breaks multi-statement transactions. Almost never what you want.</p>
<h2 id="the-settings-that-matter">the settings that matter<a class="anchor" href="#the-settings-that-matter" aria-label="link to this section">#</a></h2>
<p>Beyond size:</p>
<p><strong>Connection timeout.</strong> How long a request waits for a connection before failing. This should be short — a few seconds. A request waiting thirty seconds for a connection has already been abandoned by its caller.</p>
<p><strong>Idle timeout.</strong> Return connections to the database when not in use. Important with many application instances.</p>
<p><strong>Max lifetime.</strong> Recycle connections periodically. This handles the case where a database failover happens and your pool is holding connections to the old primary — without a max lifetime, some pools hold those indefinitely.</p>
<p><strong>Validation.</strong> Test a connection before handing it out, or handle the failure on first use. Something has to deal with connections that died while idle.</p>
<h2 id="the-diagnostic">the diagnostic<a class="anchor" href="#the-diagnostic" aria-label="link to this section">#</a></h2>
<div class="code"><span class="code-lang">sql</span><pre><code class="lang-sql">SELECT state, count(*), max(now() - state_change) AS longest
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY state;</code></pre></div>
<p>What you are looking for:</p>
<ul><li><strong>Many <code>idle in transaction</code></strong> — the worst state. A transaction is open and doing nothing, holding locks and preventing vacuum. This is an application bug: a transaction opened and not committed, usually because of an early return or an exception path.</li><li><strong>Many <code>idle</code></strong> — pool is oversized. Harmless but wasteful.</li><li><strong>Many <code>active</code> with long durations</strong> — queries are slow; the pool is not the problem.</li></ul>
<p><code>idle in transaction</code> is the one to alert on. It is always a bug and it causes outages that look like database problems and are application problems.</p>
<h2 id="the-summary">the summary<a class="anchor" href="#the-summary" aria-label="link to this section">#</a></h2>
<p>Size your pool from a load test, not from a blog post. It is smaller than you think. Use an external pooler in transaction mode if you have many application instances, and check your prepared statement behavior when you do.</p>]]></content:encoded></item><item><title>Migrations that don't wake anyone up</title><link>https://readme.news/migrations-that-dont-wake-anyone-up/</link><guid isPermaLink="true">https://readme.news/migrations-that-dont-wake-anyone-up/</guid><pubDate>Mon, 27 Apr 2026 09:00:00 +0000</pubDate><description>Data migrations at scale, done in a way that is reversible at every step and boring in the middle.</description><content:encoded><![CDATA[<p>A migration on a table with a thousand rows is a command. A migration on a table with two billion rows is a project, and the difference in approach is total.</p>
<p>Here is the pattern that works, and the specific things that go wrong.</p>
<h2 id="the-locks-that-kill-you">the locks that kill you<a class="anchor" href="#the-locks-that-kill-you" aria-label="link to this section">#</a></h2>
<p>The failure mode is almost always a lock held longer than expected, blocking every query behind it, until connections exhaust and the application falls over.</p>
<p><strong>In Postgres</strong>, these are safe (metadata-only, fast):</p>
<ul><li><code>ADD COLUMN</code> with no default, or with a non-volatile default (since PG 11).</li><li><code>DROP COLUMN</code> (marks it dead, does not rewrite).</li><li><code>ADD CONSTRAINT ... NOT VALID</code>, then <code>VALIDATE CONSTRAINT</code> separately.</li><li><code>CREATE INDEX CONCURRENTLY</code>.</li><li>Renaming things.</li></ul>
<p>These rewrite the table and hold an exclusive lock for the duration:</p>
<ul><li><code>ALTER COLUMN TYPE</code> (most of the time).</li><li><code>ADD COLUMN</code> with a volatile default.</li><li><code>SET NOT NULL</code> directly (use a <code>CHECK</code> constraint validated separately, then convert).</li></ul>
<p><strong>The one everyone hits:</strong> <code>CREATE INDEX</code> without <code>CONCURRENTLY</code> blocks writes for the entire build. On a large table that is minutes to hours. <code>CONCURRENTLY</code> takes longer and does not block, and it can fail — leaving an invalid index you must drop and retry.</p>
<p><strong>The one nobody expects:</strong> even a fast <code>ALTER TABLE</code> must acquire an exclusive lock, and it will queue behind any long-running transaction. Then everything <a class="xref" href="/the-queues-you-did-not-know-you-had/" title="The queues you did not know you had">queues</a> behind <em>it</em>. A migration that takes 5 ms can cause a 10-minute outage if a reporting query is holding a lock.</p>
<p>Set <code>lock_timeout</code> before every DDL statement:</p>
<div class="code"><span class="code-lang">sql</span><pre><code class="lang-sql">SET lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN region text;</code></pre></div>
<p>If it cannot get the lock in three seconds, it fails and you retry, rather than queueing behind something and taking the site down. This one line prevents a large share of migration incidents.</p>
<h2 id="expand-and-contract-always">expand and contract, always<a class="anchor" href="#expand-and-contract-always" aria-label="link to this section">#</a></h2>
<p>Never change a column in place. Four deploys:</p>
<p><strong>1. Expand.</strong> Add the new column, nullable. Deploy. Old code ignores it.</p>
<p><strong>2. Backfill and dual-write.</strong> Application writes both columns. Backfill existing rows in batches. Deploy.</p>
<div class="code"><span class="code-lang">sql</span><pre><code class="lang-sql">-- in a loop, with a pause between batches
UPDATE orders SET region = derive_region(country)
WHERE region IS NULL AND id BETWEEN $1 AND $2;</code></pre></div>
<p>Batch size in the thousands. Sleep between batches. Monitor replication lag and back off if it grows — a backfill that outruns replication is how you take down your read replicas.</p>
<p><strong>3. Switch reads.</strong> Read from the new column. Deploy. Still fully reversible, the old column is intact and still being written.</p>
<p><strong>4. Contract.</strong> Stop writing the old column, deploy, and drop it days later.</p>
<p>Four deploys instead of one. Every intermediate state works with both the old and new code, so any deploy can be rolled back independently.</p>
<h2 id="the-backfill-rules">the backfill rules<a class="anchor" href="#the-backfill-rules" aria-label="link to this section">#</a></h2>
<p><strong>Idempotent.</strong> It will be interrupted. It must be safe to re-run.</p>
<p><strong>Resumable.</strong> Track progress. A backfill that starts over from the beginning after a failure will never finish on a large table.</p>
<p><strong>Rate limited.</strong> Not "as fast as possible." A backfill competing with production traffic for I/O is a self-inflicted incident. Watch replication lag, watch p99 latency, and throttle.</p>
<p><strong>Observable.</strong> Log progress. "Backfilled 4.2M of 180M rows, ETA 6h" lets someone make a decision. Silence does not.</p>
<p><strong>Kill-switchable.</strong> You need to be able to stop it immediately without deploying. A flag it checks between batches.</p>
<h2 id="testing-it">testing it<a class="anchor" href="#testing-it" aria-label="link to this section">#</a></h2>
<p><strong>On a copy of production, at production size.</strong> A migration tested on a 10,000-row development database tells you the syntax is right and nothing about the runtime.</p>
<p><strong>With concurrent load.</strong> Locks only matter under contention. Run the migration against a restored copy while replaying production traffic.</p>
<p><strong>With the rollback.</strong> Actually run it. A rollback plan that has never been executed is a hypothesis.</p>
<h2 id="the-checklist">the checklist<a class="anchor" href="#the-checklist" aria-label="link to this section">#</a></h2>
<p>Before any production migration on a large table:</p>
<ul><li>[ ] Tested on production-sized data with concurrent load</li><li>[ ] <code>lock_timeout</code> set on every DDL statement</li><li>[ ] Expand-contract, not in-place</li><li>[ ] Backfill is batched, rate-limited, resumable, and killable</li><li>[ ] Rollback tested</li><li>[ ] Replication lag monitored with a threshold to pause at</li><li>[ ] Someone is watching, with the <a class="xref" href="/feature-flags-and-the-state-space-nobody-tests/" title="Feature flags and the state space nobody tests">kill switch</a>, for the duration</li><li>[ ] Not on a Friday</li></ul>
<p>That last one is not superstition. It is that the people who understand the change should be available for the two days after it, and on Friday they are not.</p>]]></content:encoded></item><item><title>The database you should have chosen</title><link>https://readme.news/the-database-you-should-have-chosen/</link><guid isPermaLink="true">https://readme.news/the-database-you-should-have-chosen/</guid><pubDate>Mon, 30 Mar 2026 09:00:00 +0000</pubDate><description>Postgres. That&#x27;s the article. Here&#x27;s the longer version, including the cases where it&#x27;s wrong.</description><content:encoded><![CDATA[<p>For a new application, the default database choice is Postgres, and the burden of proof is on anything else.</p>
<p>This is not a controversial position anymore, which is itself notable, because a decade ago it was.</p>
<h2 id="what-it-absorbed">what it absorbed<a class="anchor" href="#what-it-absorbed" aria-label="link to this section">#</a></h2>
<p>The reason the argument ended is that Postgres kept adding the things people left it for:</p>
<p><strong>JSON.</strong> <code>jsonb</code> with indexing, operators, and path queries. The document database use case — schemaless-ish data with nested structure — is handled well enough that the specialized option is rarely worth a second system.</p>
<p><strong>Full-text search.</strong> Built in, with ranking, stemming, and index support. Not as good as a dedicated search engine at large scale or with complex relevance requirements. Good enough for the search box on most applications, and one fewer system to operate.</p>
<p><strong>Vector search.</strong> <code>pgvector</code> with HNSW indexing. Again: not the best available at extreme scale, and entirely adequate for most retrieval workloads, with the enormous advantage that your vectors sit next to your metadata and you can filter and join in one query.</p>
<p>That last point is underrated. A dedicated vector database that cannot join to your relational data means you do the join in application code, badly.</p>
<p><strong>Time series.</strong> Partitioning, BRIN indexes, and extensions cover a lot of the use case.</p>
<p><strong>Geospatial.</strong> PostGIS remains the best geospatial implementation in any database, period.</p>
<p><strong><a class="xref" href="/the-queues-you-did-not-know-you-had/" title="The queues you did not know you had">Queues</a>.</strong> <code>SELECT ... FOR UPDATE SKIP LOCKED</code> gives you a correct, transactional job queue in about twenty lines. For anything under high-thousands of jobs per second — which is most systems — you do not need a dedicated queue, and the transactional property (enqueue in the same transaction as the state change) removes an entire class of consistency bug.</p>
<h2 id="the-operational-reality">the operational reality<a class="anchor" href="#the-operational-reality" aria-label="link to this section">#</a></h2>
<p><strong>One system to operate.</strong> Every additional datastore is a backup strategy, a monitoring setup, an upgrade path, a failure mode, an <a class="xref" href="/on-call-is-a-design-problem/" title="On-call is a design problem">on-call</a> runbook, and a set of consistency questions between it and everything else.</p>
<p>The cost of a second datastore is not the license. It is the permanent operational surface, and teams consistently underestimate it by a large factor.</p>
<p><strong>Transactions across your data.</strong> If your users and your documents and your embeddings are in one database, a change to all three is one transaction. Split across three systems, it is a distributed consistency problem you now own.</p>
<h2 id="when-it-is-genuinely-wrong">when it is genuinely wrong<a class="anchor" href="#when-it-is-genuinely-wrong" aria-label="link to this section">#</a></h2>
<p>Being honest about this matters, because "just use Postgres" as a reflex is the same failure mode as any other reflex.</p>
<p><strong>Extreme write throughput on a single logical dataset.</strong> Postgres scales writes vertically, well, up to a point. Past that point you need sharding, and Postgres's sharding story is real but less mature than systems designed for it from the start. If you genuinely need millions of writes per second, look elsewhere.</p>
<p><strong>Analytical queries over very large datasets.</strong> Postgres is a row store. Scanning a billion rows to compute an aggregate is what column stores are for, and the difference is orders of magnitude. Use a column store — several integrate cleanly with Postgres.</p>
<p><strong>Global multi-region with low write latency everywhere.</strong> Postgres replication is primary-based. If you need writes accepted in multiple regions with low latency, that is a distributed database problem and Postgres is not one.</p>
<p><strong>Extremely high-volume <a class="xref" href="/caching-is-the-only-optimization-that-reliably-works/" title="Caching is the only optimization that reliably works">caching</a>.</strong> Redis exists and is better at being a cache. This is a legitimate second system.</p>
<p><strong>Embedded / <a class="xref" href="/pixel-10-and-the-on-device-model-as-a-platform-feature/" title="Pixel 10 and the on-device model as a platform feature">on-device</a>.</strong> SQLite. Different problem, different answer.</p>
<h2 id="the-thing-to-actually-do">the thing to actually do<a class="anchor" href="#the-thing-to-actually-do" aria-label="link to this section">#</a></h2>
<p>Start with Postgres. Put everything in it. Measure.</p>
<p>When something is genuinely the bottleneck — and you will know, because you will have the metrics — extract that one thing to a specialized system with a clear reason.</p>
<p>That order matters. Teams that start with five specialized systems on the theory that they will need them end up operating five systems for a workload one would have handled, and the complexity is permanent.</p>
<h2 id="the-version-note">the version note<a class="anchor" href="#the-version-note" aria-label="link to this section">#</a></h2>
<p>Whatever you are running, be less than two major versions behind. Postgres's release quality is high, the upgrades are usually boring, and the performance improvements between versions are substantial and free.</p>
<p>The people who have bad Postgres upgrade experiences are the ones who skipped four versions and tried to do it in one jump during an outage.</p>]]></content:encoded></item><item><title>Schema design is the only design that lasts</title><link>https://readme.news/schema-design-is-the-only-design-that-lasts/</link><guid isPermaLink="true">https://readme.news/schema-design-is-the-only-design-that-lasts/</guid><pubDate>Fri, 06 Mar 2026 09:00:00 +0000</pubDate><description>Your code will be rewritten. Your API will be versioned. Your data model will outlive both and every mistake in it is permanent.</description><content:encoded><![CDATA[<p>Look at any system that has been running for a decade. The code has been rewritten, possibly twice. The framework changed. The language may have changed. The API has three versions.</p>
<p>The database schema is largely the same, with accretions.</p>
<p>That asymmetry is the most underappreciated fact in software architecture, and it should change how you allocate design effort.</p>
<h2 id="why-schemas-are-sticky">why schemas are sticky<a class="anchor" href="#why-schemas-are-sticky" aria-label="link to this section">#</a></h2>
<p><strong>Data outlives code.</strong> You can rewrite a service over a weekend. You cannot rewrite ten years of production data.</p>
<p><strong>Migrations are risky and get riskier with volume.</strong> A schema change on a table with a hundred rows is instant. On a table with two billion rows it is a project with a rollback plan and a maintenance window you cannot get.</p>
<p><strong>Everything couples to it.</strong> Reports, exports, downstream consumers, that one analytics job nobody owns, the integration a customer built against your read replica five years ago.</p>
<p><strong>Bad shapes calcify into application logic.</strong> If a column means two things depending on another column, every piece of code that touches it encodes that rule. Fixing the schema means fixing all of them, and by then nobody knows where they all are.</p>
<h2 id="the-decisions-that-are-expensive-to-reverse">the decisions that are expensive to reverse<a class="anchor" href="#the-decisions-that-are-expensive-to-reverse" aria-label="link to this section">#</a></h2>
<p><strong>Identity.</strong> Integer, UUID, or something else. Integers are compact and leak information — an incrementing ID tells competitors your order volume. Random UUIDs destroy index locality on insert. UUIDv7 is time-ordered and mostly solves that.</p>
<p>Pick early, because changing a primary key type across a live system with foreign keys is one of the genuinely hardest migrations there is.</p>
<p><strong>Cardinality.</strong> "A user has one address" is a decision you will regret. Almost every one-to-one relationship eventually becomes one-to-many: one email, one phone, one payment method, one team.</p>
<p>The cost of modeling it as one-to-many from the start is a join. The cost of changing it later is a migration plus every query plus every piece of application logic that assumed singularity.</p>
<p><strong>Nullability.</strong> A nullable column means every consumer must handle null forever. Make columns <code>NOT NULL</code> with a default unless the absence of a value is genuinely meaningful — and if it is, ask whether "unknown" and "not applicable" are the same thing, because they usually are not and you have just conflated them.</p>
<p><strong>Enumerated values in the schema versus a lookup table.</strong> A native enum type is fast and adding a value is a migration. A lookup table is flexible and costs a join. For a set that changes rarely (order status), enum. For a set that business users will want to edit (categories), table.</p>
<p><strong>Soft delete.</strong> <code>deleted_at IS NULL</code> on every query, forever, and one place that forgets it is a data leak. Sometimes required by regulation. Often adopted by default without thinking, and then it is permanent.</p>
<p>If you need it, consider a separate archive table instead — moving the row rather than flagging it. That keeps the hot table clean and makes the "forgot the filter" bug impossible.</p>
<p><strong>Timestamps.</strong> Always store UTC. Always store the timezone separately if local time matters semantically ("the meeting is at 9 a.m. in Berlin" is not the same fact as an instant). Always <code>timestamptz</code>, never <code>timestamp</code>, in Postgres.</p>
<h2 id="the-practices-that-make-it-survivable">the practices that make it survivable<a class="anchor" href="#the-practices-that-make-it-survivable" aria-label="link to this section">#</a></h2>
<p><strong>Constraints in the database, not just the application.</strong> A <code>CHECK</code> constraint, a foreign key, a <code>UNIQUE</code> index. Application-level validation is bypassed by the migration script, the admin tool, the data fix someone ran by hand at 2 a.m., and the second service somebody wrote.</p>
<p>The database is the only place a rule is actually enforced.</p>
<p><strong>Expand-contract for every change.</strong> Add nullable, backfill, dual-write, switch reads, remove old. Four deploys, every intermediate state reversible. This is not optional discipline; it is the only way to change a schema without a maintenance window.</p>
<p><strong>Never reuse a column for a new meaning.</strong> The old data is still in there with the old meaning. Add a column.</p>
<p><strong>Name things fully.</strong> <code>status</code> is not a name. <code>subscription_status</code> is. Abbreviations that were obvious to you in 2019 are not obvious to anyone in 2029.</p>
<p><strong>Comment the schema.</strong> Postgres has <code>COMMENT ON COLUMN</code>. Almost nobody uses it. The place to record what a column means is on the column.</p>
<h2 id="the-exercise-worth-doing">the exercise worth doing<a class="anchor" href="#the-exercise-worth-doing" aria-label="link to this section">#</a></h2>
<p>Take your main table. For each column, ask:</p>
<ol><li>What does it mean, precisely?</li><li>Can it be null, and what does null mean?</li><li>Who writes it, and is there more than one writer?</li><li>What enforces its validity?</li><li>If we needed two of these, what would break?</li></ol>
<p>You will find at least one column that means different things in different rows. That column is going to be the source of an incident, eventually, and it was a design decision someone made in ten minutes six years ago.</p>
<p>Spend the extra day on the schema. It is the only artifact you are going to be living with.</p>]]></content:encoded></item><item><title>SQLite in the browser and the end of the API round trip</title><link>https://readme.news/sqlite-in-the-browser-and-the-end-of-the-api-round-trip/</link><guid isPermaLink="true">https://readme.news/sqlite-in-the-browser-and-the-end-of-the-api-round-trip/</guid><pubDate>Sat, 07 Feb 2026 09:00:00 +0000</pubDate><description>A real relational database in WebAssembly with durable storage. What it makes possible and what it breaks.</description><content:encoded><![CDATA[<p>SQLite compiled to <a class="xref" href="/the-component-model-and-the-plugin-problem/" title="The component model and the plugin problem">WebAssembly</a>, with an origin-private filesystem backing store, gives the browser a real relational database with real SQL, real transactions, and real durability.</p>
<p>That is a bigger change to web application architecture than it first appears.</p>
<h2 id="what-it-replaces">what it replaces<a class="anchor" href="#what-it-replaces" aria-label="link to this section">#</a></h2>
<p>The default architecture for a data-heavy web application is: the browser holds almost nothing, every view is a request, and the server does the querying.</p>
<p>That produces:</p>
<ul><li>A round trip for every interaction, at whatever latency the user's network has.</li><li>An API endpoint per view, because generic endpoints are inefficient and specific ones proliferate.</li><li>A loading state for everything, and the accompanying skeleton screens.</li><li>Nothing works offline.</li><li>Server cost proportional to reads, which are the overwhelming majority of traffic.</li></ul>
<p>If the client has the data, all of that changes. A view is a query against local storage. It returns in a millisecond. There is no loading state because there is no load.</p>
<h2 id="the-practical-setup">the practical setup<a class="anchor" href="#the-practical-setup" aria-label="link to this section">#</a></h2>
<div class="code"><span class="code-lang">js</span><pre><code class="lang-js">import { sqlite3Worker1Promiser } from '@sqlite.org/sqlite-wasm';

const db = await sqlite3Worker1Promiser.v2();
await db('open', { filename: 'file:app.sqlite3?vfs=opfs' });

await db('exec', {
  sql: `select id, title, updated_at from notes
        where project_id = ? order by updated_at desc limit 50`,
  bind: [projectId],
  rowMode: 'object',
});</code></pre></div>
<p>Run it in a worker. The synchronous access handle API — which is what makes durable storage fast — is only available off the main thread, and you did not want to block the main thread with database I/O anyway.</p>
<h2 id="the-design-questions-this-forces">the design questions this forces<a class="anchor" href="#the-design-questions-this-forces" aria-label="link to this section">#</a></h2>
<p><strong>How much data does a client get?</strong> All of it is fine for a personal notes app. It is catastrophic for a system where a user may see a tiny fraction of a large corpus, and it is a security problem if the partition is not enforced server-side.</p>
<p>The general answer is a <strong>working set</strong>: the user's own data, recently accessed shared data, and lazy loading for the rest. Deciding what is in the working set is the hard design problem and it is application-specific.</p>
<p><strong>How does it sync?</strong> This is where the complexity lives. Options range from "pull changes since a watermark and last-write-wins" to full CRDT-based merge. The right answer depends on whether concurrent edits to the same record are possible and what should happen when they are.</p>
<p>Start with the simplest thing that is correct for your data. Most applications have very little genuine concurrent editing and do not need CRDTs.</p>
<p><strong>What about permissions?</strong> The client has the data, so the client can read the data, so anything the client should not see must not be synced. Your sync layer has to understand your authorization model.</p>
<p>This is the constraint that rules out <a class="xref" href="/local-first-is-finally-practical/" title="Local-first is finally practical">local-first</a> for a lot of enterprise applications, and pretending otherwise is how you build a data leak.</p>
<p><strong>What about migrations?</strong> Users have databases on their machines in old schemas. You need versioned migrations that run on the client, and you need to handle a client that has been offline across four versions.</p>
<p>Build this on day one. It is very painful to retrofit.</p>
<h2 id="what-it-is-genuinely-good-for">what it is genuinely good for<a class="anchor" href="#what-it-is-genuinely-good-for" aria-label="link to this section">#</a></h2>
<ul><li>Applications with a personal working set: notes, tasks, editors, trackers, design tools, dashboards over a user's own data.</li><li>Anything where the user expects instant interaction.</li><li>Anything that should work on a plane.</li><li>Anything where read traffic dominates and server costs scale with it.</li></ul>
<h2 id="what-it-is-not-good-for">what it is not good for<a class="anchor" href="#what-it-is-not-good-for" aria-label="link to this section">#</a></h2>
<ul><li>Large shared datasets with narrow per-user views.</li><li>Anything requiring server-authoritative state.</li><li>Anything with fine-grained dynamic permissions.</li><li>Regulatory environments where data on client devices is restricted.</li></ul>
<h2 id="the-performance-note-that-surprises-people">the performance note that surprises people<a class="anchor" href="#the-performance-note-that-surprises-people" aria-label="link to this section">#</a></h2>
<p>For a dataset that fits — and "fits" is generous, tens of megabytes is unremarkable — local SQL queries are faster than a network request by roughly three orders of magnitude.</p>
<p>That is not an optimization. It is a different category of user experience, and users notice it immediately even if they cannot say why.</p>
<p>The application that responds instantly to every action feels like a tool. The one with a spinner on every click feels like a website. That distinction is worth more than most features.</p>]]></content:encoded></item><item><title>Local-first is finally practical</title><link>https://readme.news/local-first-is-finally-practical/</link><guid isPermaLink="true">https://readme.news/local-first-is-finally-practical/</guid><pubDate>Thu, 22 Jan 2026 09:00:00 +0000</pubDate><description>CRDTs got good, sync engines got boring, and the offline-capable app stopped being a research project.</description><content:encoded><![CDATA[<p>"Local-first software" was a research paper and a manifesto in 2019. It is now a set of libraries you can actually ship, and the trade-offs have become concrete enough to reason about.</p>
<h2 id="the-pitch">the pitch<a class="anchor" href="#the-pitch" aria-label="link to this section">#</a></h2>
<p>Data lives on the user's device. The application reads and writes locally, so every interaction is instant and works offline. Changes sync to other devices and other users in the background, merging automatically.</p>
<p>The properties you get:</p>
<ul><li><strong>No loading spinners.</strong> Reads are local. The interaction latency is the render time.</li><li><strong>Offline works.</strong> Not degraded — fully functional.</li><li><strong>The user owns their data</strong>, in a file, on their machine.</li><li><strong>The server is a relay</strong>, not the source of truth, which makes it much simpler and much cheaper.</li></ul>
<p>The properties you give up are the interesting part.</p>
<h2 id="what-actually-got-solved">what actually got solved<a class="anchor" href="#what-actually-got-solved" aria-label="link to this section">#</a></h2>
<p><strong>Conflict resolution.</strong> CRDTs — conflict-free replicated data types — let two devices edit the same document offline and merge deterministically without a central arbiter. The theory is old. What is new is that the implementations got fast enough and small enough to use.</p>
<p>The specific thing that changed: document sizes. Early CRDT implementations carried enormous metadata overhead — a text document could be ten times its content in tombstones and identifiers. Modern implementations use run-length encoding and columnar formats that bring the overhead down to something reasonable, and they can compact history.</p>
<p><strong>Sync engines became a category.</strong> You no longer write the sync layer. Several production-grade options exist, some open source, some hosted, all with the mundane parts — auth, presence, storage, reconnection — handled.</p>
<p><strong>Local databases in the browser.</strong> SQLite compiled to <a class="xref" href="/the-component-model-and-the-plugin-problem/" title="The component model and the plugin problem">WebAssembly</a> with a persistent backing store means the browser can host a real relational database with real queries. That removes the "but I need to query it" objection that killed a lot of earlier local-first attempts.</p>
<h2 id="what-is-still-hard">what is still hard<a class="anchor" href="#what-is-still-hard" aria-label="link to this section">#</a></h2>
<p><strong>Authorization.</strong> This is the big one and it does not have a clean answer.</p>
<p>In a server-authoritative system, permission checks happen in one place. In a local-first system, the client has the data — so either you sync only what the user may see (which requires the server to understand your <a class="xref" href="/nodejs-24-and-the-slow-reinvention-of-the-runtime/" title="Node.js 24 and the slow reinvention of the runtime">permission model</a> and partition accordingly), or you encrypt per-scope and manage keys.</p>
<p>Both are real work. Anything with fine-grained, dynamic, row-level permissions is a poor fit and you should be honest about that before you start.</p>
<p><strong>Schema migration.</strong> Your users have data on their devices in an old schema and some of them will not open the app for eight months. You need migrations that run locally, that are forward and backward compatible, and that handle a client syncing after skipping four versions.</p>
<p>This is solvable and it is a discipline you must adopt from day one. Retrofitting it is very painful.</p>
<p><strong>Server-side logic.</strong> Anything that must be authoritative — payment, inventory decrement, <a class="xref" href="/rate-limiting-the-four-algorithms-and-when-each-is-wrong/" title="Rate limiting: the four algorithms and when each is wrong">rate limiting</a>, anything where the user must not be able to lie — still needs a server. Local-first is not "no backend." It is "the backend is smaller and does less."</p>
<p><strong>History size.</strong> CRDTs accumulate operation history. For a long-lived document with many edits, that grows. <a class="xref" href="/claude-opus-45-and-the-compaction-problem/" title="Claude Opus 4.5 and the compaction problem">Compaction</a> helps and it has consequences for concurrent editing across the compaction boundary.</p>
<h2 id="where-it-fits">where it fits<a class="anchor" href="#where-it-fits" aria-label="link to this section">#</a></h2>
<p><strong>Excellent fit:</strong> note-taking, editors, design tools, project trackers, single-user or small-team collaborative documents, anything where a user has a personal working set and expects it to be fast.</p>
<p><strong>Poor fit:</strong> anything with a large shared dataset the user only sees a slice of, anything requiring server-authoritative state, anything with complex dynamic permissions, anything regulated in a way that prohibits data on client devices.</p>
<p><strong>Mixed:</strong> most business applications, where some of the data is personal working state that should be local and some is shared authoritative state that should not be. The hybrid is more work than either pure approach and is usually correct.</p>
<h2 id="the-reason-to-care">the reason to care<a class="anchor" href="#the-reason-to-care" aria-label="link to this section">#</a></h2>
<p>The default web application architecture — every interaction is a network round trip to a server that queries a database — produces software that is slower today than desktop software was in 1998, on hardware that is thousands of times faster.</p>
<p>Everyone has normalized this. Users have not; they just do not have a word for why the app feels bad.</p>
<p>Local-first is the architecture that fixes it, and it is now practical enough that "we cannot do that" has stopped being true.</p>]]></content:encoded></item><item><title>Postgres 18's async I/O, four months in</title><link>https://readme.news/postgres-18s-async-io-four-months-in/</link><guid isPermaLink="true">https://readme.news/postgres-18s-async-io-four-months-in/</guid><pubDate>Thu, 15 Jan 2026 09:00:00 +0000</pubDate><description>The biggest storage-layer change in years is in production now. Here&#x27;s what it actually did to real workloads.</description><content:encoded><![CDATA[<p>Postgres 18 shipped with an asynchronous I/O subsystem — the largest change to how Postgres talks to storage in a very long time. Enough people have run it in production now to say something more useful than "it is fast."</p>
<h2 id="what-it-does">what it does<a class="anchor" href="#what-it-does" aria-label="link to this section">#</a></h2>
<p>Historically Postgres read from disk synchronously: a backend process issues a read, blocks, gets the page, continues. On a spinning disk that was fine — the disk was the bottleneck and you could not do better. On modern NVMe, where the device can service many requests in parallel and a single-threaded synchronous reader leaves most of the device idle, it wasted a great deal of available throughput.</p>
<p>Postgres 18 adds an I/O method abstraction:</p>
<div class="code"><pre><code>io_method = sync          # the old behavior
io_method = worker        # dedicated I/O worker processes (default)
io_method = io_uring      # Linux io_uring, if built with support</code></pre></div>
<p>Sequential scans, bitmap heap scans, and vacuum use it. Other paths do not yet; this is an incremental rollout across releases.</p>
<h2 id="what-people-are-seeing">what people are seeing<a class="anchor" href="#what-people-are-seeing" aria-label="link to this section">#</a></h2>
<p>The consistent pattern from the reports I trust:</p>
<p><strong>Large sequential scans on fast NVMe: substantial improvement.</strong> Analytical queries scanning a large table are the clearest win. This is exactly what you would predict — the workload was read-parallel and the code was not.</p>
<p><strong>Vacuum: meaningfully faster.</strong> This matters more than it sounds. Vacuum being slow is the root of a large class of Postgres operational problems, and anything that makes it finish sooner reduces bloat pressure.</p>
<p><strong>OLTP with a good cache hit rate: little change.</strong> If your working set is in shared buffers, you were not doing I/O, so making I/O faster does nothing. Most transactional workloads are in this category and should not expect much.</p>
<p><strong>Cloud block storage: mixed.</strong> Network-attached storage has enough latency and enough of its own queueing that the gains are smaller and less predictable than on local NVMe. Test rather than assume.</p>
<h2 id="the-tuning-notes">the tuning notes<a class="anchor" href="#the-tuning-notes" aria-label="link to this section">#</a></h2>
<p>Two settings matter and the defaults are conservative:</p>
<div class="code"><pre><code>io_combine_limit = 128kB        # how much adjacent I/O to merge into one request
effective_io_concurrency = 16   # how many concurrent reads to issue</code></pre></div>
<p><code>effective_io_concurrency</code> has historically been set based on spindle count, which is meaningless on NVMe. For a modern SSD, values well above the old defaults are appropriate. Measure — the curve flattens and then degrades, and where it turns depends on your device.</p>
<p>If you are on Linux with a recent kernel and can build with io_uring support, it generally outperforms the worker method by a modest margin. The worker method is the safe default and it is genuinely good.</p>
<h2 id="the-other-things-in-18-worth-knowing">the other things in 18 worth knowing<a class="anchor" href="#the-other-things-in-18-worth-knowing" aria-label="link to this section">#</a></h2>
<p><strong>Skip scan for B-tree indexes.</strong> A multi-column index on <code>(a, b)</code> can now be used for a query filtering only on <code>b</code>, when <code>a</code> has low cardinality. This eliminates a category of redundant index that people have been maintaining for years.</p>
<p><strong><code>uuidv7()</code> built in.</strong> Time-ordered UUIDs, natively. If you use UUIDs as primary keys, v7 dramatically improves index locality compared to v4, which scatters inserts across the whole B-tree. This is a real performance issue that a lot of applications have and do not know about.</p>
<p><strong>OAuth authentication support</strong>, which matters for anyone trying to eliminate static database passwords.</p>
<p><strong>Faster major-version upgrades</strong>, with statistics preserved across <code>pg_upgrade</code>. Previously you finished an upgrade with no statistics and a database that planned terribly until you ran <code>ANALYZE</code> across everything. That was a genuine outage risk and it is fixed.</p>
<h2 id="should-you-upgrade">should you upgrade<a class="anchor" href="#should-you-upgrade" aria-label="link to this section">#</a></h2>
<p>Yes, on the normal schedule — after a point release or two, with a tested rollback, having read the release notes for the incompatibilities.</p>
<p>The async I/O is the headline and for most transactional workloads the statistics preservation on upgrade and <code>uuidv7()</code> will matter more day to day.</p>
<p>As always with Postgres: the release is well-tested, the migration is usually boring, and the people who get burned are the ones who skipped four major versions and tried to do it in one jump.</p>]]></content:encoded></item><item><title>SQLite in production, seriously</title><link>https://readme.news/sqlite-in-production-seriously/</link><guid isPermaLink="true">https://readme.news/sqlite-in-production-seriously/</guid><pubDate>Fri, 07 Nov 2025 09:00:00 +0000</pubDate><description>It is not a toy database. For a large class of applications it is a better choice than the thing you&#x27;re using.</description><content:encoded><![CDATA[<p>SQLite runs on more devices than every other database combined. It is in every phone, every browser, most cars, and a large fraction of embedded systems. It is one of the most thoroughly tested pieces of software ever written — the test suite has roughly a hundred times more code than the library itself, with 100% branch coverage.</p>
<p>And a lot of engineers still classify it as "the thing you use before you get a real database."</p>
<h2 id="when-it-is-the-right-choice">when it is the right choice<a class="anchor" href="#when-it-is-the-right-choice" aria-label="link to this section">#</a></h2>
<p><strong>Read-heavy workloads on a single machine.</strong> SQLite has no network hop, no connection pool, no serialization. A query is a function call into memory-mapped pages. For reads it is frequently faster than Postgres on the same hardware by an order of magnitude, because the fastest network request is the one that does not happen.</p>
<p><strong>Applications whose data fits on one disk.</strong> Which is most applications. A terabyte NVMe drive costs very little and holds a large amount of business data. The number of applications that genuinely need distributed storage is much smaller than the number that use it.</p>
<p><strong>Anything where operational simplicity matters.</strong> No server to run, patch, monitor, back up separately, or fail over. Your backup is a file copy. Your staging environment is <code>cp prod.db staging.db</code>. Your local development environment is identical to production.</p>
<p><strong>Edge deployment.</strong> Ship the database with the application. This is why the edge-runtime ecosystem has converged on SQLite-derived systems.</p>
<h2 id="the-write-concurrency-question">the write concurrency question<a class="anchor" href="#the-write-concurrency-question" aria-label="link to this section">#</a></h2>
<p>The historical objection: SQLite serializes writes. One writer at a time.</p>
<p>This is true, and with WAL mode it is far less limiting than it sounds. Readers do not block writers and writers do not block readers. Only writer-versus-writer contends.</p>
<div class="code"><span class="code-lang">sql</span><pre><code class="lang-sql">PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON;</code></pre></div>
<p>Set those four. The defaults are conservative for embedded use and wrong for a server application.</p>
<p>A single writer on NVMe handles thousands of transactions per second if the transactions are short. The failure mode is long-running write transactions blocking others, which is a thing you should not do in any database.</p>
<p>Be honest about your actual write rate. Most applications that think they are write-heavy are doing a few hundred writes per second at peak, which SQLite handles without noticing.</p>
<h2 id="when-it-is-the-wrong-choice">when it is the wrong choice<a class="anchor" href="#when-it-is-the-wrong-choice" aria-label="link to this section">#</a></h2>
<ul><li><strong>Multiple application servers needing write access to the same data.</strong> This is the real limit. The replication ecosystem (Litestream, LiteFS, rqlite, and the various hosted variants) addresses parts of it with real trade-offs, but if you genuinely need multi-writer, use Postgres.</li><li><strong>Very large datasets with complex analytical queries.</strong> The query planner is good and it is not Postgres's, and you do not get parallel query execution.</li><li><strong>Rich types and extensions.</strong> No native JSON operators as capable as Postgres's, no PostGIS, no pgvector. There are extensions; they are not the same ecosystem.</li><li><strong>Fine-grained access control.</strong> SQLite's <a class="xref" href="/the-component-model-and-the-plugin-problem/" title="The component model and the plugin problem">security model</a> is filesystem permissions. If you need row-level security, you need something else.</li></ul>
<h2 id="the-deployment-pattern-that-works">the deployment pattern that works<a class="anchor" href="#the-deployment-pattern-that-works" aria-label="link to this section">#</a></h2>
<p>Application and database on the same machine. Vertical scaling — modern hardware is very large. WAL mode. Continuous replication to object storage for durability. Read replicas if you need geographic distribution.</p>
<p>This handles a genuinely enormous amount of traffic. Not every application, and far more than the conventional architecture assumes.</p>
<h2 id="the-actual-argument">the actual argument<a class="anchor" href="#the-actual-argument" aria-label="link to this section">#</a></h2>
<p>The default architecture for a web application — separate database server, <a class="xref" href="/connection-pooling-explained-properly/" title="Connection pooling, explained properly">connection pooling</a>, network round trips, replication topology, a managed service bill — solves problems that most applications do not have, and it costs complexity that every application pays.</p>
<p>Start with the simplest thing that could work. For a very large class of applications, that is a single machine with a file on it, and you can move later if you need to.</p>
<p>Most never need to. The ones that do will have revenue by then.</p>]]></content:encoded></item><item><title>Linux 6.16 and the steady grind of filesystem work</title><link>https://readme.news/linux-616-and-the-steady-grind-of-filesystem-work/</link><guid isPermaLink="true">https://readme.news/linux-616-and-the-steady-grind-of-filesystem-work/</guid><pubDate>Mon, 28 Jul 2025 09:00:00 +0000</pubDate><description>Faster ext4 writes, more Rust bindings, and a release with no headline feature. That&#x27;s a good sign.</description><content:encoded><![CDATA[<p>Linux 6.16 shipped this week. There is no headline feature, which is worth writing about, because a project that ships boring releases on schedule is a healthy one.</p>
<h2 id="the-notable-bits">the notable bits<a class="anchor" href="#the-notable-bits" aria-label="link to this section">#</a></h2>
<p><strong>ext4 gains support for concurrent direct I/O writes to the same file</strong> when using extent-based allocation without needing exclusive inode locking. For database workloads on ext4 this is a meaningful throughput improvement — the exclusive lock was a real serialization point on write-heavy workloads with many threads hitting one large file.</p>
<p>If you run Postgres or MySQL on ext4 with direct I/O, this is worth benchmarking after you upgrade.</p>
<p><strong>Btrfs</strong> continues its steady improvement, including better handling of large folios. <strong>XFS</strong> got more work on its atomic write support, which is one of those capabilities that unlocks meaningful optimizations in databases that can rely on it — no more double-write buffers if the filesystem can guarantee torn-write protection.</p>
<p><strong>More Rust bindings</strong> landed, including additional infrastructure for DRM drivers. The Rust-for-Linux effort continues its subsystem-by-subsystem advance.</p>
<p><strong>Futex improvements</strong> for scalability under contention, which matters for any heavily threaded userspace runtime — the JVM, Go's scheduler, and every async runtime with a work-stealing thread pool ultimately bottom out here.</p>
<h2 id="the-thing-about-boring-releases">the thing about boring releases<a class="anchor" href="#the-thing-about-boring-releases" aria-label="link to this section">#</a></h2>
<p>The Linux kernel ships roughly every nine to ten weeks. It has done so for two decades. Every release contains thousands of commits from over a thousand developers across hundreds of companies who mostly compete with each other.</p>
<p>There is no roadmap document. There is no product manager. There is a maintainer hierarchy, a merge window, a stabilization period, and a release.</p>
<p>It works better than almost any commercially-managed software project on earth, and the reason is worth thinking about: the process optimizes for <strong>not regressing</strong> above everything else. The famous "we do not break userspace" rule is not politeness, it is the constraint that makes an enormous decentralized project tractable. If the <a class="xref" href="/the-interface-is-the-product/" title="The interface is the product">interface</a> contract is inviolable, subsystems can evolve independently without coordination.</p>
<p>That is the actual lesson for people building large systems. Stable interfaces between components are what let you have many independent teams. Every organization that struggles with cross-team coordination is, underneath, struggling with unstable interfaces.</p>
<h2 id="the-maintainer-question">the maintainer question<a class="anchor" href="#the-maintainer-question" aria-label="link to this section">#</a></h2>
<p>Worth noting alongside: the kernel has a succession problem. A meaningful number of critical subsystems are maintained by people who have been doing it for fifteen or twenty years, and the pipeline of replacements is thin.</p>
<p>This is true of most critical infrastructure software and it does not get attention because it is not an incident until it is. The number of load-bearing open source projects with exactly one active maintainer remains one of the scariest statistics in computing.</p>
<p>If your company depends on the kernel — and it does — funding maintainers is a better use of security budget than most of the things in your security budget.</p>]]></content:encoded></item><item><title>Meta buys half of Scale AI and most of its leadership</title><link>https://readme.news/meta-buys-half-of-scale-ai-and-most-of-its-leadership/</link><guid isPermaLink="true">https://readme.news/meta-buys-half-of-scale-ai-and-most-of-its-leadership/</guid><pubDate>Fri, 13 Jun 2025 09:00:00 +0000</pubDate><description>$14.3 billion for 49%, and Alexandr Wang moves to run a new superintelligence lab. The data layer just got picked.</description><content:encoded><![CDATA[<p>Meta is investing $14.3 billion for a 49% non-voting stake in Scale AI. Scale's CEO Alexandr Wang moves to Meta to lead a new "Superintelligence Labs" group, taking several colleagues with him.</p>
<p>Structurally this is an acquihire with an equity stake attached, arranged to avoid the antitrust review a full acquisition would trigger. It is the third such deal in a year following similar structures at other labs, and regulators have noticed the pattern.</p>
<h2 id="why-scale">why Scale<a class="anchor" href="#why-scale" aria-label="link to this section">#</a></h2>
<p>Scale's business is data: labeling, annotation, evaluation, and increasingly the human expert layer that produces high-quality demonstrations for RLHF and reasoning training.</p>
<p>That business is unglamorous and it is a genuine bottleneck. Every frontier lab needs enormous amounts of carefully constructed training data, especially for post-training. Web scrape is free and getting exhausted. What is scarce is expert-produced reasoning traces, verified solutions, and adversarial evaluations, and Scale is the largest supplier.</p>
<p>Meta's Llama 4 launch underwhelmed. Their internal read, based on the reporting, was that the gap was not compute — Meta has enormous compute — but post-training quality. Buying the data layer is a direct response to that diagnosis.</p>
<h2 id="the-conflict-of-interest-problem">the conflict of interest problem<a class="anchor" href="#the-conflict-of-interest-problem" aria-label="link to this section">#</a></h2>
<p>Scale's customers include most of Meta's competitors. Several immediately began reducing their reliance, which is the obvious response when your data vendor is half-owned by a rival.</p>
<p>Scale has said the business remains independent and customer data is siloed. That is probably true operationally and it does not matter, because the risk assessment is not about what Scale does, it is about what Scale <em>could</em> do, and no chief information security officer is going to sign off on that.</p>
<p>Expect a scramble toward Surge, Turing, Mercor, Invisible, and in-house annotation teams over the next two quarters.</p>
<h2 id="the-talent-market">the talent market<a class="anchor" href="#the-talent-market" aria-label="link to this section">#</a></h2>
<p>This deal is one data point in the most aggressive AI talent market anyone has seen. Compensation packages for senior researchers have reached numbers that sound like typos, and the poaching is happening in public.</p>
<p>Two things worth noting:</p>
<p><strong>The concentration is extreme.</strong> The number of people who have personally led a frontier pretraining run is in the low hundreds globally. That is a genuinely scarce input and it prices accordingly.</p>
<p><strong>It is probably a bubble in the specific sense that it will correct.</strong> Talent premiums of this magnitude assume the individual contributor is the bottleneck. As tooling matures and recipes become public — and they are becoming public, fast — the premium compresses. It always has.</p>
<h2 id="what-it-means-for-everyone-else">what it means for everyone else<a class="anchor" href="#what-it-means-for-everyone-else" aria-label="link to this section">#</a></h2>
<p>If you are not a frontier lab, the actionable read is about <em>your</em> data.</p>
<p>The scarce resource in applied AI is not model access, it is well-constructed, domain-specific evaluation and training data. Nobody can buy your production logs, your customer support transcripts, your annotated failure cases. That is the asset.</p>
<p>Most companies are sitting on it and not using it. Start by building an evaluation set from real failures. That is worth more than any model upgrade and it appreciates rather than depreciating.</p>]]></content:encoded></item>
</channel>
</rss>
