<?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: Bob Bass</title>
    <description>The latest articles on DEV Community by Bob Bass (@bobdotjs).</description>
    <link>https://dev.to/bobdotjs</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%2F301013%2Ffcc9e84e-a5af-477b-8895-825e3a6c7097.jpg</url>
      <title>DEV Community: Bob Bass</title>
      <link>https://dev.to/bobdotjs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bobdotjs"/>
    <language>en</language>
    <item>
      <title>From PGlite to Production Postgres</title>
      <dc:creator>Bob Bass</dc:creator>
      <pubDate>Wed, 26 Aug 2026 11:54:00 +0000</pubDate>
      <link>https://dev.to/bobdotjs/from-pglite-to-production-postgres-loo</link>
      <guid>https://dev.to/bobdotjs/from-pglite-to-production-postgres-loo</guid>
      <description>&lt;p&gt;There is a specific moment in a project's life where an embedded database stops being enough. Usually it arrives without warning. Someone else wants to use the thing. Or you deploy a server component and it needs to read the same rows the browser has been writing. Or you want a cron job, a webhook, a second device, a teammate. The database that has been living inside your app now needs an address.&lt;/p&gt;

&lt;p&gt;If your prototype was built on &lt;a href="https://pglite.dev" rel="noopener noreferrer"&gt;PGlite&lt;/a&gt;, the good news is that this is one of the easiest graduations in the business, because you were already running Postgres. Not something Postgres-shaped. Actual Postgres, compiled to WebAssembly, running in process.&lt;/p&gt;

&lt;p&gt;This originally ran on the &lt;a href="https://layerbase.com/blog/pglite-to-production-postgres" rel="noopener noreferrer"&gt;Layerbase blog&lt;/a&gt;. The timing is why everyone is talking about this right now. On August 11, 2026, &lt;a href="https://neon.com/blog/electric-joins-neon" rel="noopener noreferrer"&gt;Electric joined the Neon team inside Databricks&lt;/a&gt;, and the number in that post is the one worth staring at: PGlite went from 1 million to 13 million weekly downloads in a year. The thesis behind it is that coding agents are driving the cost of building software toward zero, so there is about to be an enormous number of small applications. A lot of those start embedded, because embedded is the fastest way to get something working. Some fraction of them will get real users. That fraction needs this guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why PGlite graduates cleanly
&lt;/h2&gt;

&lt;p&gt;Most embedded databases make you pay on the way out. You wrote against SQLite's type affinity or a document store's query language, and moving to Postgres means rewriting a schema, rewriting queries, and rediscovering every place your code assumed the old engine's behavior.&lt;/p&gt;

&lt;p&gt;PGlite does not have that problem, and it is worth being precise about why. It is not a reimplementation of Postgres and it is not Postgres running inside an emulated Linux VM. As the project puts it, it is "simply Postgres in WASM," using Postgres' own single-user mode with a custom I/O path into JavaScript. Your &lt;code&gt;CREATE TABLE&lt;/code&gt; statements are Postgres DDL. Your queries are Postgres SQL. Your &lt;code&gt;SERIAL&lt;/code&gt; columns, your &lt;code&gt;jsonb&lt;/code&gt;, your constraints and defaults all mean exactly what they mean on a server.&lt;/p&gt;

&lt;p&gt;So the migration is a dump and a restore. That is the entire shape of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: dump the PGlite database
&lt;/h2&gt;

&lt;p&gt;PGlite ships a companion package that runs the real &lt;code&gt;pg_dump&lt;/code&gt; against a live PGlite instance. Install it alongside PGlite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @electric-sql/pglite @electric-sql/pglite-tools
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pgDump&lt;/code&gt; takes your PGlite instance and hands back a &lt;code&gt;File&lt;/code&gt; you can read as text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PGlite&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@electric-sql/pglite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pgDump&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@electric-sql/pglite-tools/pg_dump&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;writeFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PGlite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./pgdata&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dump&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pgDump&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pglite-dump.sql&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole step in Node. The dump is standard SQL: schema, then data. &lt;code&gt;pgDump&lt;/code&gt; appends &lt;code&gt;--inserts&lt;/code&gt; by default, so the data comes back as ordinary &lt;code&gt;INSERT&lt;/code&gt; statements rather than &lt;code&gt;COPY&lt;/code&gt; blocks, which makes the file restorable into any compatible PostgreSQL server. Compatible is doing some work in that sentence: check that the target's major version is not older than the one PGlite was built on, and that any extension your schema leans on exists there too.&lt;/p&gt;

&lt;p&gt;If your data lives in the browser instead of on disk, the same call works against a browser-backed instance. Open the database at whatever data directory you have been using, &lt;code&gt;idb://my-app&lt;/code&gt; for IndexedDB, run &lt;code&gt;pgDump&lt;/code&gt;, and download the returned &lt;code&gt;File&lt;/code&gt; instead of writing it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PGlite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idb://my-app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dump&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pgDump&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;download&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pglite-dump.sql&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you kept the data in OPFS at &lt;code&gt;opfs-ahp://my-app&lt;/code&gt;, the same call works, but that filesystem only runs inside a Web Worker, and it does not work in Safari at all: a Postgres install opens more sync access handles than Safari allows. Run the dump from the worker that owns the database.&lt;/p&gt;

&lt;p&gt;Two things worth knowing before you move on. &lt;code&gt;pgDump&lt;/code&gt; runs &lt;code&gt;DEALLOCATE ALL&lt;/code&gt; on the connection when it finishes, so any prepared statements you were holding are gone. And the dump does not carry your session &lt;code&gt;search_path&lt;/code&gt;, so if you were relying on a non-default one, note what it was before you dump and set it on the other side.&lt;/p&gt;

&lt;p&gt;Open the file. It is human-readable SQL, and reading it is a good sanity check that everything you expected made it in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: create a hosted Postgres
&lt;/h2&gt;

&lt;p&gt;Two paths, both real. Through the dashboard, pick Postgres at &lt;a href="https://layerbase.com/create/postgresql" rel="noopener noreferrer"&gt;layerbase.com/create/postgresql&lt;/a&gt;, name it, and you have a database in under a minute with a connection string on the detail page.&lt;/p&gt;

&lt;p&gt;From a terminal, the &lt;a href="https://layerbase.com/docs/cli" rel="noopener noreferrer"&gt;Layerbase CLI&lt;/a&gt; does the same thing. Cloud commands authenticate with a personal API key from your dashboard settings, which is also what makes this scriptable later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-g&lt;/span&gt; layerbase
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LAYERBASE_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk_...

lbase cloud create my-app &lt;span class="nt"&gt;--engine&lt;/span&gt; postgresql
lbase cloud connection-string my-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;postgresql://layerbase:&amp;lt;password&amp;gt;@your-host.cloud.layerbase.dev:5432/my-app?sslmode=require
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The free tier covers this: two databases, 5 GB, one branch each, no credit card. That is deliberate, and I will come back to why it stays that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: restore the dump
&lt;/h2&gt;

&lt;p&gt;You have a SQL file and a connection string, so this is the standard Postgres move:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;psql &lt;span class="nt"&gt;-X&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATABASE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; pglite-dump.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-X&lt;/code&gt; skips your &lt;code&gt;.psqlrc&lt;/code&gt; so nothing you set up locally leaks into the restore, and &lt;code&gt;ON_ERROR_STOP=1&lt;/code&gt; halts on the first problem rather than ploughing through and leaving you guessing. On a fresh database there usually is not a first problem, because the dump was produced by &lt;code&gt;pg_dump&lt;/code&gt; from a Postgres that agrees with the one receiving it. If it does stop partway, start clean: drop and recreate the database, or delete it in the dashboard and make another, rather than re-running the file over a half-loaded schema.&lt;/p&gt;

&lt;p&gt;If you would rather not have psql installed, the CLI can push the file for you with &lt;code&gt;lbase import pglite-dump.sql --target my-app --yes&lt;/code&gt;, and the &lt;a href="https://layerbase.com/docs/cloud/query-console" rel="noopener noreferrer"&gt;query console&lt;/a&gt; in the dashboard will run statements against the database directly from the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: swap the connection string
&lt;/h2&gt;

&lt;p&gt;This is the step people brace for and it is anticlimactic. Wherever you had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PGlite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./pgdata&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;you now have a normal Postgres client pointed at an environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your queries do not change. If you were using an ORM, its Postgres dialect was already the dialect you were using. What changes is that the database is now a thing on the network with credentials and TLS, which is exactly the property you needed when you started this.&lt;/p&gt;

&lt;p&gt;If you are on Node, you will likely see the &lt;code&gt;sslmode=require&lt;/code&gt; treated as &lt;code&gt;verify-full&lt;/code&gt; warning. Swap &lt;code&gt;require&lt;/code&gt; for &lt;code&gt;verify-full&lt;/code&gt; in the URL. The write-up is &lt;a href="https://layerbase.com/blog/node-postgres-ssl-modes-warning" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually get on the other side
&lt;/h2&gt;

&lt;p&gt;Graduating buys you the operational layer an embedded database has no way to give you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backups you did not have to think about.&lt;/strong&gt; Every destructive path takes a backup first, deletion is refused if a final backup cannot be secured, and the most recent backup is never pruned. That applies on the free tier, not just paid ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Branching.&lt;/strong&gt; Fork a writable copy of the database before a risky migration, run it, and throw the branch away if it goes badly. It works on 16 engines, Postgres among them. Details in the &lt;a href="https://layerbase.com/docs/cloud/branching" rel="noopener noreferrer"&gt;branching docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A query console&lt;/strong&gt;, so debugging production data does not require a psql session and a laptop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sleep and wake, instead of a bill.&lt;/strong&gt; Free databases sleep after 60 idle minutes and wake when you reconnect, which takes a few seconds and looks like a slow first connection rather than an error. No keep-alive cron job, no dashboard button, no restore flow. That mechanic is the whole reason the free tier can stay: a sleeping database holds no RAM and no CPU, so we are not carrying a fleet of abandoned side projects at a loss. I wrote out the full economics in &lt;a href="https://layerbase.com/blog/the-free-tier-that-stays" rel="noopener noreferrer"&gt;the free tier that stays&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Postgres is one of 18 engines on &lt;a href="https://layerbase.com" rel="noopener noreferrer"&gt;Layerbase Cloud&lt;/a&gt;, which matters mostly for the next thing you add. When the app that outgrew PGlite wants a cache or a search index, it goes on the same account instead of a new vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest fork in the road
&lt;/h2&gt;

&lt;p&gt;Not every prototype should graduate, and I would rather say that plainly than pretend otherwise.&lt;/p&gt;

&lt;p&gt;If your app is genuinely local-first, if the data belongs to the person using it and never needs to be reachable from a server, PGlite is the right answer and you should keep it. That is the case it was built for, and it is very good at it. Same if you are using it as a test fixture or a sandbox: an in-process Postgres that starts in milliseconds is hard to beat. And if what you want is a real Postgres server on your own machine rather than a hosted one, the &lt;a href="https://layerbase.com/docs/cli" rel="noopener noreferrer"&gt;Layerbase CLI&lt;/a&gt; runs those locally too, no Docker required.&lt;/p&gt;

&lt;p&gt;The fork is not about scale or seriousness. It is one question: does something other than this one client need to read or write these rows? The day the answer becomes yes, you have a ten-minute job and no rewrite, because you picked a database that was Postgres the whole time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://layerbase.com/create/postgresql" rel="noopener noreferrer"&gt;Create a Postgres database&lt;/a&gt; and run the dump. If you want the background on the engine itself first, the &lt;a href="https://layerbase.com/db/postgresql" rel="noopener noreferrer"&gt;PostgreSQL page&lt;/a&gt; covers what we run and how, and &lt;a href="https://layerbase.com/docs/cloud/connecting" rel="noopener noreferrer"&gt;connecting to a database&lt;/a&gt; covers the client side in more depth.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>webassembly</category>
    </item>
    <item>
      <title>version `GLIBC_2.38' not found, and the release check that stops it</title>
      <dc:creator>Bob Bass</dc:creator>
      <pubDate>Tue, 25 Aug 2026 14:02:00 +0000</pubDate>
      <link>https://dev.to/bobdotjs/version-glibc238-not-found-and-the-release-check-that-stops-it-4njh</link>
      <guid>https://dev.to/bobdotjs/version-glibc238-not-found-and-the-release-check-that-stops-it-4njh</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;./qdrant: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.38' not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have seen that, you already know the shape of the problem: a binary that runs perfectly on the machine that built it, and cannot start at all on a slightly older one. What is less obvious is that you can hit it three completely different ways without changing a line of your own code, and that none of the usual release gates will catch any of them.&lt;/p&gt;

&lt;p&gt;This originally ran on the &lt;a href="https://layerbase.com/blog/glibc-version-not-found-binaries" rel="noopener noreferrer"&gt;Layerbase blog&lt;/a&gt;. We ship prebuilt binaries for a couple of dozen database engines across five platforms. In one month we got bitten three times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why glibc breaks in one direction only
&lt;/h2&gt;

&lt;p&gt;glibc symbols are versioned. When a binary uses a function, the linker records the minimum glibc version that provides the exact behavior it compiled against, as a symbol version like &lt;code&gt;GLIBC_2.38&lt;/code&gt;. At startup the dynamic linker checks those requirements against the glibc actually installed.&lt;/p&gt;

&lt;p&gt;The compatibility runs one way. A binary built on an older glibc runs on newer systems, because newer glibc keeps old symbol versions around. A binary built on a newer glibc does &lt;strong&gt;not&lt;/strong&gt; run on older systems, because the symbol version it names simply does not exist there.&lt;/p&gt;

&lt;p&gt;So your build machine's glibc becomes a floor on every machine that can run your output. Build on Ubuntu 24.04 (glibc 2.39) and you have silently dropped Ubuntu 22.04 (glibc 2.35), which is still an LTS release plenty of people run.&lt;/p&gt;

&lt;p&gt;The failure is total, not partial. The process never reaches &lt;code&gt;main&lt;/code&gt;. There is no graceful degradation and no useful diagnostic beyond the line above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways it reaches you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The upstream project's own binary requires it.&lt;/strong&gt; Official SQLite binaries for Linux require GLIBC 2.38 or newer. Not a mistake on their part, just a build environment choice, and it makes them unusable on Ubuntu 22.04 and older. Our fix was to stop shipping the official Linux binary and build SQLite from the amalgamation source inside &lt;code&gt;ubuntu:20.04&lt;/code&gt;, which gives a 2.31 floor. Nothing about the resulting binary is worse; it simply has a lower floor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The upstream project changes their build between releases.&lt;/strong&gt; Qdrant 1.16.3's &lt;code&gt;linux-gnu&lt;/code&gt; tarball needed only &lt;code&gt;GLIBC_2.34&lt;/code&gt;. Version 1.18.3 of the same asset needed &lt;code&gt;GLIBC_2.38&lt;/code&gt;. No announcement, no major version bump, and the only visible symptom was that half our test matrix went red: Ubuntu 24.04 (glibc 2.39) was fine, Ubuntu 22.04 was not.&lt;/p&gt;

&lt;p&gt;The fix there was different. Qdrant also publishes a &lt;code&gt;linux-musl&lt;/code&gt; tarball, which is statically linked and references no glibc symbols at all. Static binaries pass this problem trivially, which is worth remembering as an option whenever a project offers both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Your base image moves underneath you.&lt;/strong&gt; This is the sneakiest one. We extracted CouchDB from the official &lt;code&gt;couchdb&lt;/code&gt; Docker image, taking &lt;code&gt;/opt/couchdb&lt;/code&gt; but not the system libraries the bundled Erlang runtime links against. That image tracks Debian stable, and at 3.5.2 it moved from bookworm to trixie.&lt;/p&gt;

&lt;p&gt;So the extracted tree silently inherited a different distro's libraries. On Ubuntu 22.04 the Erlang VM needed &lt;code&gt;GLIBC_2.38&lt;/code&gt; and never started. On Ubuntu 24.04 it started and then died differently, because &lt;code&gt;crypto.so&lt;/code&gt; needed &lt;code&gt;OPENSSL_3.4.0&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{application_start_failure,config,{undef,{crypto,info_fips,[]}}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two distinct failures from one upstream base-image bump. And because we only logged "start timeout, then ECONNREFUSED," neither cause was visible until someone read the engine's own log.&lt;/p&gt;

&lt;p&gt;That one was fixed by building from the project's apt repository pinned to &lt;code&gt;jammy&lt;/code&gt; inside &lt;code&gt;ubuntu:22.04&lt;/code&gt;, so the toolchain is pinned to our oldest supported target rather than to whatever a third party's base image happens to be this month.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part worth stealing: check what your artifacts actually require
&lt;/h2&gt;

&lt;p&gt;All three shipped green. Every existing gate passed. They only failed two repositories downstream, in a consumer's Ubuntu 22.04 CI, because &lt;strong&gt;nothing in the release pipeline ever looked at what the binaries required.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the real bug. The individual glibc problems are ordinary; the absence of a check is what let them all reach users.&lt;/p&gt;

&lt;p&gt;The check is not complicated. For every Linux archive in a release, extract it, find every ELF file, read the highest &lt;code&gt;GLIBC_x.y.z&lt;/code&gt; symbol version referenced, and fail if anything exceeds your declared floor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# highest glibc symbol version a single ELF file requires&lt;/span&gt;
readelf &lt;span class="nt"&gt;-V&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'GLIBC_[0-9.]*'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-V&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;objdump -T&lt;/code&gt; works as a fallback where &lt;code&gt;readelf&lt;/code&gt; is unavailable. A few details make the difference between a check that works and one people disable:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find ELF files by magic bytes, not by extension.&lt;/strong&gt; Binaries in these archives are often extensionless, and plenty of &lt;code&gt;.so&lt;/code&gt; files live in subdirectories you did not expect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip non-ELF payloads gracefully.&lt;/strong&gt; JVM engines ship jars with nothing to inspect. If your check errors on those, it becomes noise and gets bypassed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static binaries pass trivially.&lt;/strong&gt; musl, Go, and Zig builds reference no glibc symbols, so they return nothing and pass. That is correct, not a gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put the floor in one constant.&lt;/strong&gt; Ours is &lt;code&gt;GLIBC_FLOOR="2.35"&lt;/code&gt;, matching Ubuntu 22.04, the oldest target we support and the base image our build containers use. One constant, one place to change it when the support window moves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip macOS and Windows archives.&lt;/strong&gt; There is no glibc there, and trying to inspect them produces confusing failures.&lt;/p&gt;

&lt;p&gt;We wired that into every release workflow. It would have caught all three incidents above at build time rather than in someone else's CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general version
&lt;/h2&gt;

&lt;p&gt;Anywhere you redistribute a compiled artifact, the build environment is part of the artifact's contract, and it is a part nobody declares. It gets inherited from a base image, from a CI runner's default, or from whatever a third party built their release on.&lt;/p&gt;

&lt;p&gt;So state your floor explicitly, build on it deliberately, and add a gate that reads the requirement out of the artifact rather than trusting that it matches. The check costs seconds and it fails loudly in the one place you can still do something about it.&lt;/p&gt;

&lt;p&gt;If you only take one thing: &lt;strong&gt;the glibc version of your build machine is a compatibility decision you are making whether or not you know it.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;We run this check across every engine we ship at &lt;a href="https://layerbase.com" rel="noopener noreferrer"&gt;Layerbase&lt;/a&gt;, because "works on the build machine" turned out to be a guarantee about nothing at all. The full write-up, including the three incidents, is on the &lt;a href="https://layerbase.com/blog/glibc-version-not-found-binaries" rel="noopener noreferrer"&gt;Layerbase blog&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>docker</category>
      <category>devops</category>
      <category>ci</category>
    </item>
    <item>
      <title>MongoDB vs FerretDB on the same hardware: the benchmark, and what we had to fix to run it fairly</title>
      <dc:creator>Bob Bass</dc:creator>
      <pubDate>Sat, 22 Aug 2026 12:03:00 +0000</pubDate>
      <link>https://dev.to/bobdotjs/mongodb-vs-ferretdb-on-the-same-hardware-the-benchmark-and-what-we-had-to-fix-to-run-it-fairly-46hi</link>
      <guid>https://dev.to/bobdotjs/mongodb-vs-ferretdb-on-the-same-hardware-the-benchmark-and-what-we-had-to-fix-to-run-it-fairly-46hi</guid>
      <description>&lt;p&gt;A prospect evaluating us for a latency-sensitive, write-heavy workload asked a fair question: how does FerretDB actually compare to the MongoDB they run today? I did not have an honest answer. What I had was other people's numbers, mostly a well-known third-party run at 1 billion records where MongoDB wins writes by a wide margin. That result is real and I am not going to argue with it. It also is not the question I was asked. The prospect is not running a billion records on a benchmark rig. They are running a working set on a box they pay a flat monthly rate for.&lt;/p&gt;

&lt;p&gt;This originally ran on the &lt;a href="https://layerbase.com/blog/mongodb-vs-ferretdb-benchmark" rel="noopener noreferrer"&gt;Layerbase blog&lt;/a&gt;. We measured on our own hardware. The benchmark then spent most of its time telling me about us instead of about MongoDB, which is why this post exists.&lt;/p&gt;

&lt;p&gt;Two of the findings were misconfigurations in our fleet that had been quietly costing every customer performance. Three more were bugs in the benchmark harness itself, which meant the first full round of numbers was garbage and had to be thrown away. The MongoDB comparison, which is what I set out to get, ended up being the fourth most interesting thing I learned.&lt;/p&gt;

&lt;p&gt;If you want the background on what FerretDB is, the &lt;a href="https://layerbase.com/blog/mongodb-vs-ferretdb" rel="noopener noreferrer"&gt;MongoDB vs FerretDB comparison&lt;/a&gt; covers the wire protocol, the feature gaps, and the licensing difference. This post is only about how the two perform on one specific box.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Everything below ran on the same hardware profile: an OVH d2-8 instance, 4 vCPU and 8 GB of RAM, Debian 13, ZFS 2.3.2 with the ARC capped at 2 GiB. That is not a benchmark rig chosen to look good. It is the exact flavor behind our Dedicated 8GB preset at $65 a month, which is what a customer with this workload would actually buy.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;YCSB 0.18.0-SNAPSHOT&lt;/li&gt;
&lt;li&gt;10 million records loaded, 1 million operations per run, 600 second cap per run&lt;/li&gt;
&lt;li&gt;Workload C (100% read), workload B (95% read / 5% update), workload A (50% read / 50% update)&lt;/li&gt;
&lt;li&gt;16, 64 and 256 client threads&lt;/li&gt;
&lt;li&gt;Write concern 1 on both sides&lt;/li&gt;
&lt;li&gt;MongoDB 8.0.29 and FerretDB 2.7, one arm per box, boxes torn down afterward&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A 10 million record YCSB dataset is roughly a 10 GiB working set. On an 8 GB box, that is deliberate. This profile does not fit in memory, so storage layout matters, and storage layout is exactly what I wanted to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first bug was ours: 128K records over 8K pages
&lt;/h2&gt;

&lt;p&gt;FerretDB stores documents as JSONB in PostgreSQL. PostgreSQL writes in 8 KiB pages. Our ZFS dataset that holds user data, &lt;code&gt;tank/users&lt;/code&gt;, was sitting on the ZFS default recordsize of 128K.&lt;/p&gt;

&lt;p&gt;An 8 KiB page write into a 128 KiB record is a read-modify-write of the whole 128 KiB record. Every dirty page. On every box in the fleet.&lt;/p&gt;

&lt;p&gt;This is not an exotic finding. It is the first line of every "PostgreSQL on ZFS" tuning guide written in the last decade. We had simply never checked, because nothing was on fire. No customer had complained, no alert had fired, no dashboard was red. It was not hurting anyone visibly. It was just leaving a large multiple of read throughput on the floor, silently, for everyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second bug was also ours: PostgreSQL on stock memory
&lt;/h2&gt;

&lt;p&gt;While I was in there, I looked at the PostgreSQL configuration the FerretDB backend was running with. It was stock. &lt;code&gt;shared_buffers&lt;/code&gt; at 128MB, which is the compiled-in default, on a box with 8 GB of RAM.&lt;/p&gt;

&lt;p&gt;Same category of problem as the first: nothing broken, nothing alerting, just a database told to use a rounding error's worth of the memory it had been given.&lt;/p&gt;

&lt;p&gt;Neither of these was a bug in the sense of a crash or a wrong answer. They were both defaults nobody had revisited, which is the kind of thing a benchmark is unreasonably good at finding, because a benchmark is the only workload that complains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the harness lied to us
&lt;/h2&gt;

&lt;p&gt;Round one finished. The numbers looked odd. Throughput barely moved between 16, 64 and 256 threads, which is not how a client-server database behaves unless something is wrong.&lt;/p&gt;

&lt;p&gt;Three things were wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YCSB silently ignored our thread count.&lt;/strong&gt; We were passing &lt;code&gt;-p threads=64&lt;/code&gt;. The property YCSB actually reads is &lt;code&gt;threadcount&lt;/code&gt;. An unknown property is not an error in YCSB and produces no warning, so every single round-one run executed at the default: one thread. Every number in that round measured single-threaded performance while labeled otherwise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The driver capped the connection pool at 100.&lt;/strong&gt; The YCSB MongoDB binding uses the Java driver's default &lt;code&gt;maxPoolSize&lt;/code&gt; of 100 unless you set &lt;code&gt;mongodb.maxconnections&lt;/code&gt;. So the 256-thread rows are not 256 concurrent connections. They are 256 client threads queuing over roughly 100 connections. The cap applies to every arm equally, so the comparison stands, but "256" is a client-thread count and not a concurrency claim.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The CSV parser matched nothing.&lt;/strong&gt; Our result extractor ran &lt;code&gt;grep -E 'Throughput(ops/sec)'&lt;/code&gt; over the YCSB output. In an extended regex, those parentheses are a capture group, not literal characters, so the pattern searches for &lt;code&gt;Throughputops/sec&lt;/code&gt;, which appears in no file we have ever generated.&lt;/p&gt;

&lt;p&gt;Round one's relative deltas survived all of this, because every arm was broken identically. Its absolute numbers did not survive, and absolute numbers are the entire point of publishing a benchmark. We deleted them and re-ran everything on a corrected harness. Every number below is from the corrected generation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ablation
&lt;/h2&gt;

&lt;p&gt;Once I knew the recordsize and the memory settings were both wrong, I could not just fix both and report the combined win. Two changes and one result tells you nothing about which change did the work.&lt;/p&gt;

&lt;p&gt;So we ran an ablation: five fresh boxes, one variable each, identical loads. OVH bills these by the hour, so the whole thing cost about $5.55.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;16K recordsize alone&lt;/strong&gt; was worth 3.7x to 4.3x over stock on reads at real concurrency. The single largest lever, and it is a one-line dataset property.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory tuning alone&lt;/strong&gt; was worth roughly 1.0x on reads. Not a typo. On its own, against 128K records, it was actively negative at 256 threads, because more dirty buffers over a 16x write amplification factor produced checkpoint storms rather than throughput. This is the finding I would have gotten wrong if I had shipped both changes together and taken credit for the sum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both together&lt;/strong&gt;, which is the profile we are now making the default, gave 4.0x to 4.3x on reads and 2.4x to 3.0x on the 50/50 workload against the ablation's own control box, the arm among the five that changed nothing and kept 128K records with stock PostgreSQL memory settings. That control is not the same box as the "FerretDB stock" column in the tables below, which was built a day earlier and ran slower on the 50/50 mix; measured against that column instead, the same tuned arm computes 3.6x to 4.3x. Both comparisons are on our hardware with the same 10 million record working set, and the cross-box caveat in footnote 4 is exactly why they differ. Update p99 on the stock arm sat around 2 seconds and collapsed to somewhere between 0.1 and 0.8 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relaxed commit durability&lt;/strong&gt; owned writes: 4.3x to 5.3x over stock, with update p99 between 19 and 281 ms. This one is an explicit customer opt-in and will never be a default, because it trades the last instant of acknowledged writes if the machine loses power. That is a decision a customer gets to make about their own data, not one we make for them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One oddity I cannot explain and am recording anyway: the relaxed-durability profile is about 0.9x the tuned default on pure reads. Reproducible across runs, no theory that survives contact with the data. It is why we label that opt-in as write-workload-only rather than as a general speed setting.&lt;/p&gt;

&lt;p&gt;There was also a superseded arm: an earlier box using an 8K recordsize measured consistently below the 16K arms on all three workloads. 16K beat 8K, which is not what I expected going in, and is why the ablation existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;All of the following is the corrected harness generation, same hardware profile on both sides. Columns are MongoDB 8.0.29 stock, FerretDB 2.7 on our old stock configuration, FerretDB on the new tuned default with durability intact, and FerretDB with the relaxed-durability opt-in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workload C, 100% read, ops/sec
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Threads&lt;/th&gt;
&lt;th&gt;MongoDB&lt;/th&gt;
&lt;th&gt;FerretDB stock&lt;/th&gt;
&lt;th&gt;FerretDB tuned default&lt;/th&gt;
&lt;th&gt;FerretDB durability opt-in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;755&lt;/td&gt;
&lt;td&gt;879&lt;/td&gt;
&lt;td&gt;3,888&lt;/td&gt;
&lt;td&gt;3,458&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;756&lt;/td&gt;
&lt;td&gt;946&lt;/td&gt;
&lt;td&gt;4,065&lt;/td&gt;
&lt;td&gt;3,635&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;256 (pool)&lt;/td&gt;
&lt;td&gt;753&lt;/td&gt;
&lt;td&gt;952&lt;/td&gt;
&lt;td&gt;3,851&lt;/td&gt;
&lt;td&gt;3,522&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tuned default versus MongoDB: 5.1x to 5.4x.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workload B, 95% read / 5% update, ops/sec
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Threads&lt;/th&gt;
&lt;th&gt;MongoDB&lt;/th&gt;
&lt;th&gt;FerretDB stock&lt;/th&gt;
&lt;th&gt;FerretDB tuned default&lt;/th&gt;
&lt;th&gt;FerretDB durability opt-in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;700&lt;/td&gt;
&lt;td&gt;884&lt;/td&gt;
&lt;td&gt;3,611&lt;/td&gt;
&lt;td&gt;3,624&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;750&lt;/td&gt;
&lt;td&gt;931&lt;/td&gt;
&lt;td&gt;3,919&lt;/td&gt;
&lt;td&gt;3,767&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;256 (pool)&lt;/td&gt;
&lt;td&gt;712&lt;/td&gt;
&lt;td&gt;933&lt;/td&gt;
&lt;td&gt;3,526&lt;/td&gt;
&lt;td&gt;3,386&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tuned default versus MongoDB: 5.0x to 5.2x.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workload A, 50% read / 50% update, ops/sec
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Threads&lt;/th&gt;
&lt;th&gt;MongoDB&lt;/th&gt;
&lt;th&gt;FerretDB stock&lt;/th&gt;
&lt;th&gt;FerretDB tuned default&lt;/th&gt;
&lt;th&gt;FerretDB durability opt-in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;491&lt;/td&gt;
&lt;td&gt;412&lt;/td&gt;
&lt;td&gt;1,631&lt;/td&gt;
&lt;td&gt;2,916&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;464&lt;/td&gt;
&lt;td&gt;491&lt;/td&gt;
&lt;td&gt;1,752&lt;/td&gt;
&lt;td&gt;3,020&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;256 (pool)&lt;/td&gt;
&lt;td&gt;465&lt;/td&gt;
&lt;td&gt;368&lt;/td&gt;
&lt;td&gt;1,586&lt;/td&gt;
&lt;td&gt;2,795&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tuned default versus MongoDB: 3.3x to 3.8x. Durability opt-in versus MongoDB: 5.9x to 6.5x. Note the second column: our old stock FerretDB configuration is roughly at parity with MongoDB here, between 0.8x and 1.1x. The tuning is the story, not the engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Update p99 latency, workload A, milliseconds
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Threads&lt;/th&gt;
&lt;th&gt;MongoDB&lt;/th&gt;
&lt;th&gt;FerretDB stock&lt;/th&gt;
&lt;th&gt;FerretDB tuned default&lt;/th&gt;
&lt;th&gt;FerretDB durability opt-in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;156&lt;/td&gt;
&lt;td&gt;202&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;664&lt;/td&gt;
&lt;td&gt;1,447&lt;/td&gt;
&lt;td&gt;388&lt;/td&gt;
&lt;td&gt;94&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;256 (pool)&lt;/td&gt;
&lt;td&gt;1,682&lt;/td&gt;
&lt;td&gt;2,529&lt;/td&gt;
&lt;td&gt;849&lt;/td&gt;
&lt;td&gt;281&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Footnotes, which travel with the numbers
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;"256 (pool)" is a client-thread count, not 256 connections.&lt;/strong&gt; The YCSB MongoDB driver caps its pool at the default 100 unless &lt;code&gt;mongodb.maxconnections&lt;/code&gt; is set, so 256 threads queue over roughly 100 connections on every column. The cap is symmetric and the comparisons stand, but true 256-connection concurrency was not exercised. Every arm saturates the 4 vCPU box by 64 threads anyway; nothing gains from 64 to 256.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MongoDB is IO-bound on this hardware profile.&lt;/strong&gt; A 2 GiB ARC plus a 3.5 GiB WiredTiger cache against a roughly 10 GiB working set. Its flat ceiling near 750 reads per second is a real result for this box class rather than a harness artifact, and it would improve on larger presets with more memory. Read every MongoDB column as "MongoDB on a memory-starved 8 GB box", because that is what it is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FerretDB stock ran PostgreSQL with &lt;code&gt;max_connections=100&lt;/code&gt;,&lt;/strong&gt; which is the stock configuration under test, so its 256-thread cells may also reflect connection limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-box note.&lt;/strong&gt; MongoDB and stock FerretDB ran on boxes built on 2026-08-18; the tuned default and the durability opt-in ran on the 2026-08-19 ablation boxes. Same flavor, same pinned image inputs, same corrected harness generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The durability opt-in is a write tool.&lt;/strong&gt; It is about 0.9x the tuned default on pure reads, reproducible and unexplained, as noted above.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The same profile up and down the preset ladder
&lt;/h2&gt;

&lt;p&gt;Everything above is one box. The obvious next question is what the tuned default does on the presets either side of it, so we ran the identical corrected harness and the identical tuned default profile on the other two dedicated flavors: the $35 preset and the $120 preset, hourly OVH boxes torn down when the runs finished.&lt;/p&gt;

&lt;p&gt;One thing to get out of the way before the table. Both of these boxes ran FerretDB only. There is no MongoDB arm at 4 GB or at 16 GB, so none of the MongoDB multiples above travel up or down this ladder. This is FerretDB against itself on different hardware, nothing more.&lt;/p&gt;

&lt;p&gt;Throughput at 64 client threads, ops/sec, tuned default profile with durability intact:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Preset&lt;/th&gt;
&lt;th&gt;Flavor&lt;/th&gt;
&lt;th&gt;Workload C, 100% read&lt;/th&gt;
&lt;th&gt;Workload B, 95/5&lt;/th&gt;
&lt;th&gt;Workload A, 50/50&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dedicated 4GB, $35&lt;/td&gt;
&lt;td&gt;d2-4, 2 vCPU / 4 GB&lt;/td&gt;
&lt;td&gt;2,141&lt;/td&gt;
&lt;td&gt;1,995&lt;/td&gt;
&lt;td&gt;1,208&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dedicated 8GB, $65&lt;/td&gt;
&lt;td&gt;d2-8, 4 vCPU / 8 GB&lt;/td&gt;
&lt;td&gt;4,065&lt;/td&gt;
&lt;td&gt;3,919&lt;/td&gt;
&lt;td&gt;1,752&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dedicated 16GB, $120&lt;/td&gt;
&lt;td&gt;r3-16, 2 vCPU / 16 GB&lt;/td&gt;
&lt;td&gt;5,274&lt;/td&gt;
&lt;td&gt;4,857&lt;/td&gt;
&lt;td&gt;2,177&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;64 threads is where every arm in the ablation saturated, which is why it is the one column worth putting side by side. The full 16, 64 and 256 thread grid stays with the raw results. Update p99 on workload A at the same thread count runs 407 ms on the 4 GB box, 388 ms on the 8 GB box and 283 ms on the 16 GB box.&lt;/p&gt;

&lt;p&gt;This is not a clean memory ladder and I am not going to present it as one. The $65 preset is 4 vCPU. Both the $35 and the $120 presets are 2 vCPU. So the 16 GB box beating the 8 GB box, 1.3x on pure reads and 1.2x on the 50/50 mix on our hardware against the same 10 million record working set, is a RAM result achieved with half the cores, and it is the clearest evidence in this whole exercise that working set versus RAM is the variable that matters on this workload. The 4 GB box is short on both counts at once. The record count, the 2 GiB ARC cap and the 16K recordsize were held constant across all three; only &lt;code&gt;shared_buffers&lt;/code&gt; scaled with the box, at 1 GB, 2 GB and 4 GB.&lt;/p&gt;

&lt;p&gt;The 4 GB box ran genuinely memory-tight and the numbers say so plainly. After the 2 GiB ARC, 1 GB of &lt;code&gt;shared_buffers&lt;/code&gt; and the YCSB JVM sharing the same machine, roughly 740 MB was free. Against a 10 GiB working set that is not a tight fit, it is no fit at all. It is the slowest row on every workload, and on the 50/50 mix it lands below the 8 GB box rather than somewhere proportional to price. Its read-mostly workload peaked at 16 threads, at 2,090 ops/sec, and got slower at 64, which is the shape of a box with no memory to spend on more concurrency rather than the shape of a box scaling. All three of its 50/50 cells hit the 600 second cap, so those figures are sustained ten-minute rates rather than completed one-million-op runs. Its load phase ingested at 1,425 ops/sec against 3,001 on the 16 GB box.&lt;/p&gt;

&lt;p&gt;I would rather publish that row than leave it out. The honest reading is that the $35 preset is sized for a working set that fits in it, and the number above is what it does when you hand it one that does not. As with everything else here, these are dedicated tier figures on one profile of hardware, not a statement about Layerbase performance generally.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that costs at MongoDB Atlas
&lt;/h2&gt;

&lt;p&gt;We did not benchmark Atlas. I want to be completely clear about that before the table, because a price table next to a performance table invites a comparison I did not earn.&lt;/p&gt;

&lt;p&gt;What I can compare is what you pay for the hardware. Atlas prices verified 2026-08-19 from &lt;a href="https://www.mongodb.com/pricing" rel="noopener noreferrer"&gt;mongodb.com/pricing&lt;/a&gt;, converted at 730 hours per month, compute only.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Rate&lt;/th&gt;
&lt;th&gt;Monthly&lt;/th&gt;
&lt;th&gt;vCPU / RAM / Storage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Atlas M10&lt;/td&gt;
&lt;td&gt;$0.08/hr&lt;/td&gt;
&lt;td&gt;about $58&lt;/td&gt;
&lt;td&gt;2 / 2 GB / 10 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Atlas M20&lt;/td&gt;
&lt;td&gt;$0.20/hr&lt;/td&gt;
&lt;td&gt;about $146&lt;/td&gt;
&lt;td&gt;2 / 4 GB / 20 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Atlas M30&lt;/td&gt;
&lt;td&gt;$0.54/hr&lt;/td&gt;
&lt;td&gt;about $394&lt;/td&gt;
&lt;td&gt;2 / 8 GB / 40 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layerbase Dedicated 4GB&lt;/td&gt;
&lt;td&gt;flat&lt;/td&gt;
&lt;td&gt;$35&lt;/td&gt;
&lt;td&gt;2 / 4 GB / 25 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layerbase Dedicated 8GB&lt;/td&gt;
&lt;td&gt;flat&lt;/td&gt;
&lt;td&gt;$65&lt;/td&gt;
&lt;td&gt;4 / 8 GB / 50 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layerbase Dedicated 16GB&lt;/td&gt;
&lt;td&gt;flat&lt;/td&gt;
&lt;td&gt;$120&lt;/td&gt;
&lt;td&gt;2 / 16 GB / 100 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Atlas monthly figures are compute alone. Storage, backup and egress bill on top of them. Our prices are flat and include the storage listed.&lt;/p&gt;

&lt;p&gt;The honest version of the comparison is this: MongoDB, running on a box with equal or better specs than an M30, did 464 operations per second on the 50/50 workload. Tuned FerretDB on our $65 box did 1,752 on the same workload on the same hardware profile. Whether Atlas at $394 a month does better than our self-hosted MongoDB did is a question this benchmark does not answer, and I am not going to pretend otherwise.&lt;/p&gt;

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

&lt;p&gt;This is one hardware profile. Everything above is bounded by it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The box is deliberately memory-starved.&lt;/strong&gt; A 10 GiB working set on 8 GB of RAM is where storage layout dominates, which is precisely why it exposed our recordsize problem. On a box where the working set fits in memory, the gap between these arms will be much smaller and the ranking may change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At 1 billion records the write picture inverts.&lt;/strong&gt; The third-party data I mentioned at the top has MongoDB winning writes at that scale, and nothing here contradicts it. Our numbers describe a 10 million record working set on a 4 vCPU box, not a billion records on a benchmark cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The architectures are not equivalent.&lt;/strong&gt; Atlas M-tiers are three-node replica sets. Our dedicated preset is a single node with backups and branching. That is a price-for-workload comparison, not an architecture-parity comparison, and if you need a replica set today then Atlas has something we do not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We did not run Atlas.&lt;/strong&gt; Said twice on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;These numbers describe the dedicated tier.&lt;/strong&gt; Different storage path, different neighbors, different everything. Do not read them as "Layerbase performance" in general.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Our own earlier comparison said MongoDB likely wins write-heavy workloads,&lt;/strong&gt; and I am not retracting that so much as bounding it. It holds at scale and on hardware where MongoDB is not IO-bound. It did not hold on this box, against a properly configured PostgreSQL backend, which is a narrower claim than either of us probably wanted.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What changes for customers
&lt;/h2&gt;

&lt;p&gt;The 16K recordsize and the tuned memory profile are becoming the default. They are rolling out behind a staged canary rather than all at once, because a storage property change on a fleet is exactly the kind of thing that deserves to go slowly, and because the ablation showed the memory half is capable of being negative when it lands without the recordsize half. Existing databases pick it up as they move through the rollout. Nobody has to ask for it and nobody pays extra for it.&lt;/p&gt;

&lt;p&gt;The relaxed-durability profile is coming as an explicit opt-in toggle for write-heavy workloads, with the tradeoff stated in plain language at the point where you flip it. It is a real 4x to 5x on writes and it can lose the last instant of acknowledged writes on a hard crash. Both of those sentences will be on the same screen.&lt;/p&gt;

&lt;p&gt;And the thing Atlas does not offer at any tier: &lt;a href="https://layerbase.com/blog/branching-with-mongodb" rel="noopener noreferrer"&gt;branching&lt;/a&gt;. A branch of your database, on the same wire protocol, for a migration rehearsal or a CI run or an agent that you would rather not point at production. If you already have data in MongoDB, the &lt;a href="https://layerbase.com/migrate/mongodb-atlas" rel="noopener noreferrer"&gt;Atlas migration page&lt;/a&gt; copies collections, documents and indexes into a managed FerretDB database and verifies the counts against your source, and the &lt;a href="https://layerbase.com/blog/migrating-from-mongodb-to-ferretdb" rel="noopener noreferrer"&gt;migration guide&lt;/a&gt; covers the compatibility checks worth running before you commit.&lt;/p&gt;

&lt;p&gt;To be exact about what is on offer: MongoDB itself is not creatable in Layerbase Cloud for licensing reasons, so the hosted document story is FerretDB, which is Apache 2.0 licensed, speaks the MongoDB wire protocol, and is built on the DocumentDB extensions that Microsoft released under MIT and donated to the Linux Foundation. MongoDB does run locally through &lt;a href="https://layerbase.com/desktop/download" rel="noopener noreferrer"&gt;Layerbase Desktop&lt;/a&gt; and the &lt;a href="https://layerbase.com/docs/cli" rel="noopener noreferrer"&gt;Layerbase CLI&lt;/a&gt;, which is how the MongoDB arm of this benchmark existed in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The standing offer
&lt;/h2&gt;

&lt;p&gt;The most useful thing this benchmark produced was not a table. It was the discovery that we had been shipping a storage default nobody had audited, found only because someone asked a question we could not answer from memory.&lt;/p&gt;

&lt;p&gt;So the offer stands: if you are evaluating us and your workload shape is not represented above, tell me what it looks like and we will run it on our hardware at our cost and send you the numbers, including the ones that do not flatter us. That is roughly $5 and an afternoon, and based on this week it pays for itself.&lt;/p&gt;

&lt;p&gt;You can start a FerretDB database at &lt;a href="https://layerbase.com/create/ferretdb" rel="noopener noreferrer"&gt;layerbase.com/create/ferretdb&lt;/a&gt; in the meantime.&lt;/p&gt;

</description>
      <category>mongodb</category>
      <category>javascript</category>
      <category>database</category>
      <category>performance</category>
    </item>
    <item>
      <title>Cloudflare D1 exports round every integer past 2^53</title>
      <dc:creator>Bob Bass</dc:creator>
      <pubDate>Fri, 21 Aug 2026 14:16:00 +0000</pubDate>
      <link>https://dev.to/bobdotjs/cloudflare-d1-exports-round-every-integer-past-253-51m2</link>
      <guid>https://dev.to/bobdotjs/cloudflare-d1-exports-round-every-integer-past-253-51m2</guid>
      <description>&lt;p&gt;The obvious way to get data out of Cloudflare D1 is &lt;code&gt;wrangler d1 export&lt;/code&gt;. Cloudflare generates a SQL dump, you load it into SQLite, and you are done. It is the vendor's own tool, so it should be the highest-fidelity path out.&lt;/p&gt;

&lt;p&gt;We tested it before shipping a D1 importer, and it is not. Any integer above 2^53 comes out of the dump as a different number, and then changes storage class on the way back in.&lt;/p&gt;

&lt;p&gt;This originally ran on the &lt;a href="https://layerbase.com/blog/cloudflare-d1-export-integer-precision" rel="noopener noreferrer"&gt;Layerbase blog&lt;/a&gt;. Cloudflare does document the cause. It is one sentence on their &lt;a href="https://developers.cloudflare.com/d1/best-practices/import-export-data/" rel="noopener noreferrer"&gt;import and export page&lt;/a&gt;: "Any numeric value in a column is affected by JavaScript's 52-bit precision for numbers." That sentence reads like a footnote about the JSON API. It is not. It is also about the file you were planning to migrate with.&lt;/p&gt;

&lt;p&gt;Here is what it actually costs, measured end to end, and the rule we took away from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;Store the largest signed 64-bit integer, a plain &lt;code&gt;1.0&lt;/code&gt;, and nothing exotic:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;big&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="nb"&gt;REAL&lt;/span&gt;&lt;span class="p"&gt;);&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;t&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9223372036854775807&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it back with a cast to text, so the value is not routed through a number on the way out:&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;SELECT&lt;/span&gt; &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;big&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;big&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;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;9223372036854775807|integer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exact, as expected. The database is fine. Now export and open the dump:&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="nv"&gt;"t"&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nv"&gt;"big"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nv"&gt;"r"&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;9223372036854776000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;9223372036854775807&lt;/code&gt; became &lt;code&gt;9223372036854776000&lt;/code&gt;. Off by 193.&lt;/p&gt;

&lt;p&gt;Load that dump into SQLite and it gets worse, because the rounded literal no longer fits in a signed 64-bit integer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sqlite&amp;gt; SELECT typeof(big), CAST(big AS TEXT) FROM t;
real|9.22337203685478e+18
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The column is still declared &lt;code&gt;INTEGER&lt;/code&gt;. The value in it is now a float. Nothing errored, the table is there, and the row count matches.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;REAL&lt;/code&gt; column has a quieter version of the same problem. &lt;code&gt;1.0&lt;/code&gt; exports as bare &lt;code&gt;1&lt;/code&gt;. Because that column is declared &lt;code&gt;REAL&lt;/code&gt;, SQLite's type affinity converts it back to &lt;code&gt;1.0&lt;/code&gt; on import and you get away with it. In a column with no declared type, which SQLite allows and plenty of schemas use, the same &lt;code&gt;1&lt;/code&gt; stays an integer and the storage class has silently changed under you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a dump does this
&lt;/h2&gt;

&lt;p&gt;A JavaScript number is a double. Doubles carry 53 bits of integer precision, so every integer up to 2^53 (9007199254740992) is exact and past that there are gaps. The dump generator formats each value as a JavaScript number on its way into the text of the file, which puts every one of your values through that funnel.&lt;/p&gt;

&lt;p&gt;The database never held a wrong value. The renderer produced one.&lt;/p&gt;

&lt;p&gt;Who this hits: snowflake ids, Discord and Twitter ids, nanosecond timestamps, bitfields, hashes kept as integers, and any 64-bit key handed to you by another system. All of them look like ordinary integers and all of them live above 2^53.&lt;/p&gt;

&lt;p&gt;What makes it nasty is that nothing looks wrong afterwards. No error, no warning, row counts match on both sides, and &lt;code&gt;SELECT * FROM users LIMIT 10&lt;/code&gt; in a dashboard shows you numbers that look about right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The inference that gets you
&lt;/h2&gt;

&lt;p&gt;The reasoning that leads people here is sound and wrong: "the JSON query API returns numbers, and JSON numbers are doubles, so the API is lossy. Use the dump instead, it is a file of SQL text."&lt;/p&gt;

&lt;p&gt;The dump is a file of SQL text generated by the same JavaScript. Text output does not mean the value avoided a double, it means the double got printed.&lt;/p&gt;

&lt;p&gt;The useful version of the rule is not about which endpoint you pick. It is about where the value gets turned into text:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A value that crosses a JSON boundary &lt;strong&gt;as a number&lt;/strong&gt; is suspect, whatever produced it.&lt;/li&gt;
&lt;li&gt;A value that crosses &lt;strong&gt;as a string&lt;/strong&gt; is fine, as long as the string was produced inside the database.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The other half: CAST is lossy on a REAL
&lt;/h2&gt;

&lt;p&gt;Once you accept that, the fix looks obvious. Do not ask for numbers, ask for text: &lt;code&gt;SELECT CAST(col AS TEXT)&lt;/code&gt;. That is correct for integers, and it quietly loses data on floats.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sqlite&amp;gt; SELECT CAST(3.141592653589793 AS TEXT);
3.14159265358979
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifteen significant digits, and the original had sixteen. That string does not parse back to the double you started with. Almost every migration tool that reaches for &lt;code&gt;CAST&lt;/code&gt; as the safe option is making this trade without noticing, because the value still looks like a number and it is only wrong in the last place or two.&lt;/p&gt;

&lt;p&gt;SQLite's &lt;code&gt;printf&lt;/code&gt; has a format that does round-trip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sqlite&amp;gt; SELECT printf('%!.20g', 3.141592653589793);
3.141592653589793116
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty significant digits is more than the seventeen a double needs, so it parses back bit for bit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The encoding that survives
&lt;/h2&gt;

&lt;p&gt;So the whole recipe, done inside SQLite before anything crosses a wire:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;typeof(x)&lt;/code&gt; alongside every value, so the storage class is carried explicitly instead of inferred at the other end&lt;/li&gt;
&lt;li&gt;integers as exact decimal text via &lt;code&gt;CAST(x AS TEXT)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;reals through &lt;code&gt;printf('%!.20g', x)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;text and blobs as &lt;code&gt;hex(x)&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every value then arrives as ASCII text or NULL, and nothing numeric is ever handed to a JavaScript number.&lt;/p&gt;

&lt;p&gt;Two edge cases we hit doing this for real, both worth knowing before you write your own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Infinity has no JSON representation.&lt;/strong&gt; It has to go back into the target as a SQL literal (&lt;code&gt;9e999&lt;/code&gt;) rather than as a value the transport carries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A TEXT column can hold bytes that are not valid UTF-8.&lt;/strong&gt; SQLite lets you, and any layer that decodes the payload as UTF-8 will mangle them. They have to be bound as a blob and cast back to text on insert.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The principle underneath all of it: &lt;strong&gt;do type-preserving encoding inside the database engine, never in the transport layer.&lt;/strong&gt; Do it in the engine and fidelity is a property of the SQL you wrote. Do it in the transport and fidelity depends on what JSON, a wire proxy, or a driver's type mapping happens to do that day, which is not something you can test once and rely on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking data you already moved
&lt;/h2&gt;

&lt;p&gt;If you migrated a D1 database with an export at some point, this is cheap to check. Against the source, per table with a suspect column:&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;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;t&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;big&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'integer'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;big&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;9007199254740992&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any non-zero count is rows that could not have survived the dump intact.&lt;/p&gt;

&lt;p&gt;On the copy, the fingerprint is the storage class rather than the value:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;big&lt;/span&gt;&lt;span class="p"&gt;),&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;t&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;real&lt;/code&gt; in a column the source reported as &lt;code&gt;integer&lt;/code&gt; is the flip described above. Comparing the values themselves is harder than it looks, because reading them back through most clients puts them through a double a second time. Compare &lt;code&gt;CAST(big AS TEXT)&lt;/code&gt; on both sides, not &lt;code&gt;big&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we ended up building
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://layerbase.com" rel="noopener noreferrer"&gt;Layerbase&lt;/a&gt; D1 importer never calls the export endpoint. It reads your rows over D1's query API with the encoding above applied inside SQLite, which is what makes an int64 id arrive exactly as stored rather than nearly as stored.&lt;/p&gt;

&lt;p&gt;Routing around the dump turned out to fix two other things for free. Cloudflare's export blocks other requests to the database while it runs, so exporting a production D1 is downtime you have to schedule; reads do not block, so the copy runs while your Worker keeps serving. And export refuses outright on a database containing a virtual table, which quietly means "no full-text search", while a read-based copy recreates the FTS5 table and flags it for a rebuild.&lt;/p&gt;

&lt;p&gt;The honest cost of reading a live database instead of dumping a frozen one: the copy is not a single point-in-time snapshot. We count rows before and after and report, per table, whether anything was written mid-copy. Migrate from a quiet database, or pause writes, if you need an exact instant.&lt;/p&gt;

&lt;p&gt;If you are moving a D1 database, start at &lt;a href="https://layerbase.com/migrate/cloudflare-d1" rel="noopener noreferrer"&gt;layerbase.com/migrate/cloudflare-d1&lt;/a&gt;. The step-by-step version, including what changes in your application code, is in &lt;a href="https://layerbase.com/blog/migrating-from-cloudflare-d1-to-layerbase" rel="noopener noreferrer"&gt;Migrating from Cloudflare D1 to Layerbase&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>sqlite</category>
      <category>cloudflare</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The SSL modes 'prefer', 'require', and 'verify-ca' are treated as aliases for 'verify-full': what this warning means</title>
      <dc:creator>Bob Bass</dc:creator>
      <pubDate>Thu, 20 Aug 2026 22:28:03 +0000</pubDate>
      <link>https://dev.to/bobdotjs/the-ssl-modes-prefer-require-and-verify-ca-are-treated-as-aliases-for-verify-full-what-5537</link>
      <guid>https://dev.to/bobdotjs/the-ssl-modes-prefer-require-and-verify-ca-are-treated-as-aliases-for-verify-full-what-5537</guid>
      <description>&lt;p&gt;You deploy a Node app that talks to a hosted Postgres, and somewhere in the logs this shows up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Warning: SECURITY WARNING: The SSL modes 'prefer', 'require', and 'verify-ca' are treated as aliases for 'verify-full'.
In the next major version (pg-connection-string v3.0.0 and pg v9.0.0), these modes will adopt standard libpq semantics, which have weaker security guarantees.
To prepare for this change:
- If you want the current behavior, explicitly use 'sslmode=verify-full'
- If you want libpq compatibility now, use 'uselibpqcompat=true&amp;amp;sslmode=require'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing failed. Queries run fine. But "SECURITY WARNING" in capital letters is not something you scroll past comfortably, and on Vercel the line gets classified as error-level output, which makes it look worse than it is.&lt;/p&gt;

&lt;p&gt;This originally ran on the &lt;a href="https://layerbase.com/blog/node-postgres-ssl-modes-warning" rel="noopener noreferrer"&gt;Layerbase blog&lt;/a&gt;. The short version is below; the receipts for &lt;em&gt;why&lt;/em&gt; providers still hand out &lt;code&gt;sslmode=require&lt;/code&gt; are in a measured &lt;a href="https://layerbase.com/blog/postgres-sslmode-client-matrix" rel="noopener noreferrer"&gt;8-client sslmode matrix&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Your app is not broken and your connection is not insecure. Today, node-postgres treats &lt;code&gt;sslmode=require&lt;/code&gt; as full certificate verification, which is the strong behavior.&lt;/li&gt;
&lt;li&gt;Nothing has changed yet. The warning is about a future major version of the &lt;code&gt;pg&lt;/code&gt; package. As of this writing the latest release is pg 8.23.0, and the v9 it warns about has not shipped.&lt;/li&gt;
&lt;li&gt;The fix is one word in your connection string: change &lt;code&gt;sslmode=require&lt;/code&gt; to &lt;code&gt;sslmode=verify-full&lt;/code&gt;. Same security you have today, warning gone, and your app keeps that security after the future change lands.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If that is all you needed, you can stop here. The rest of the post explains what the modes actually promise, why the maintainers are changing them, and the edge cases where the one-word fix is not the right one.&lt;/p&gt;

&lt;h2&gt;
  
  
  You did not upgrade pg. So why now?
&lt;/h2&gt;

&lt;p&gt;The warning lives in &lt;code&gt;pg-connection-string&lt;/code&gt;, the small library the &lt;code&gt;pg&lt;/code&gt; driver uses to parse connection URLs. It was added in &lt;a href="https://github.com/brianc/node-postgres/pull/3473" rel="noopener noreferrer"&gt;pull request #3473&lt;/a&gt; and first shipped in &lt;code&gt;pg-connection-string&lt;/code&gt; 2.10.0 in mid-January 2026.&lt;/p&gt;

&lt;p&gt;Here is the part that confuses people: &lt;code&gt;pg&lt;/code&gt; depends on that library with a version range, not a pinned version. So you do not need to upgrade &lt;code&gt;pg&lt;/code&gt; to start seeing the warning. A fresh install, a lockfile refresh, or a teammate running an update can silently move the transitive dependency to 2.10.0 or later, and an app on pg 8.16.x that was quiet last month starts warning today. That is why a lot of people started seeing it around the same time without shipping any database changes.&lt;/p&gt;

&lt;p&gt;The warning fires when the connection string is parsed, before any network connection is made, and only once per process. A pool of twenty connections logs it one time, not twenty.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the SSL modes actually promise
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;sslmode&lt;/code&gt; comes from libpq, the official Postgres C client that &lt;code&gt;psql&lt;/code&gt; and most non-JavaScript drivers are built on. In libpq, the modes are a ladder (&lt;a href="https://www.postgresql.org/docs/current/libpq-ssl.html" rel="noopener noreferrer"&gt;official reference&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;disable&lt;/code&gt;&lt;/strong&gt;: no encryption.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;prefer&lt;/code&gt;&lt;/strong&gt;: encrypt if the server offers it, otherwise connect anyway. No verification of who you are talking to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;require&lt;/code&gt;&lt;/strong&gt;: always encrypt, but do not check the server's certificate at all. The connection is private, but you have no proof it is private with the right party. Think of it as a sealed envelope handed to a stranger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;verify-ca&lt;/code&gt;&lt;/strong&gt;: encrypt and check that the certificate was signed by a trusted authority, but do not check that the name on it matches the server you dialed. Any certificate from that authority passes, including one issued for someone else's server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;verify-full&lt;/code&gt;&lt;/strong&gt;: encrypt, check the signer, and check the name. This is the only mode that fully protects you from someone impersonating your database server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So in standard Postgres semantics, &lt;code&gt;require&lt;/code&gt; is a much weaker promise than its name suggests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What node-postgres does today, and what will change
&lt;/h2&gt;

&lt;p&gt;node-postgres never followed the libpq ladder. Today, &lt;code&gt;prefer&lt;/code&gt;, &lt;code&gt;require&lt;/code&gt;, &lt;code&gt;verify-ca&lt;/code&gt;, and &lt;code&gt;verify-full&lt;/code&gt; all do the same thing: full certificate and hostname verification, using the certificate authorities your system already trusts. In other words, everything is silently upgraded to &lt;code&gt;verify-full&lt;/code&gt;. That is the "treated as aliases" the warning is describing, and it is why the warning is not telling you that you are in danger. You currently have the strongest behavior no matter which of those modes you wrote.&lt;/p&gt;

&lt;p&gt;The maintainers plan to align with libpq in the next major version, so that &lt;code&gt;sslmode=require&lt;/code&gt; means what it means everywhere else. You can already see the shape of it: &lt;code&gt;uselibpqcompat=true&lt;/code&gt; switches on the same parsing branch today. That is reasonable for consistency, but it has a sharp edge. Under libpq semantics, a connection string that says &lt;code&gt;sslmode=require&lt;/code&gt; and nothing else drops from "verify everything" to "encrypt only, verify nothing", and one that also passes &lt;code&gt;sslrootcert&lt;/code&gt; drops to &lt;code&gt;verify-ca&lt;/code&gt; behavior: the certificate chain is checked against that authority, the hostname is not. Managed-provider strings almost never carry &lt;code&gt;sslrootcert&lt;/code&gt;, so the first case is the one most apps will land in. Connections that used to be authenticated stop being authenticated, with no error and no visible change. That is exactly the kind of downgrade that deserves a loud warning years in advance, which is what this is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: sslmode=verify-full
&lt;/h2&gt;

&lt;p&gt;If your Postgres host uses certificates signed by a public authority, which covers Neon, Supabase, &lt;a href="https://layerbase.com" rel="noopener noreferrer"&gt;Layerbase&lt;/a&gt;, and effectively every managed Postgres provider, the fix is to say explicitly what you are already getting.&lt;/p&gt;

&lt;p&gt;Neon hands out connection strings in this shape (&lt;a href="https://neon.com/docs/connect/connect-from-any-app" rel="noopener noreferrer"&gt;their docs&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;postgresql://user:password@ep-xxx-pooler.us-east-2.aws.neon.tech/neondb?sslmode=require&amp;amp;channel_binding=require
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change one word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;postgresql://user:password@ep-xxx-pooler.us-east-2.aws.neon.tech/neondb?sslmode=verify-full&amp;amp;channel_binding=require
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave &lt;code&gt;channel_binding&lt;/code&gt; alone. node-postgres does support channel binding, but not through that URL parameter: you turn it on in code with the &lt;code&gt;enableChannelBinding: true&lt;/code&gt; client option, which makes &lt;code&gt;pg&lt;/code&gt; pick SCRAM-SHA-256-PLUS when the server offers it (shipped in pg 8.14.0). The parameter itself is only parsed and carried through by &lt;code&gt;pg-connection-string&lt;/code&gt;; &lt;code&gt;pg&lt;/code&gt; does not map or enforce it, and wiring it up to libpq's meaning is &lt;a href="https://github.com/brianc/node-postgres/pull/3746" rel="noopener noreferrer"&gt;an open pull request&lt;/a&gt;. So it is a harmless passthrough for Node, and other clients that do act on it benefit from it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://supabase.com/docs/guides/database/connecting-to-postgres" rel="noopener noreferrer"&gt;Supabase&lt;/a&gt; connection strings do not include an &lt;code&gt;sslmode&lt;/code&gt; parameter at all, so a Supabase project does not trigger this warning by default. If you are seeing it with Supabase, the parameter was added on your side, and the same one-word change applies.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://layerbase.com" rel="noopener noreferrer"&gt;Layerbase Cloud&lt;/a&gt; connection strings do say &lt;code&gt;sslmode=require&lt;/code&gt;, so yes, our users on node-postgres see this warning too. If you are connecting from Node, swapping to &lt;code&gt;sslmode=verify-full&lt;/code&gt; is safe and free: our server certificates are issued by a public authority, and Node verifies against the trust store it ships with.&lt;/p&gt;

&lt;p&gt;We hand out &lt;code&gt;require&lt;/code&gt; rather than &lt;code&gt;verify-full&lt;/code&gt; because the same string has to work in every client, and stock &lt;code&gt;psql&lt;/code&gt; fails on a bare &lt;code&gt;verify-full&lt;/code&gt;. libpq looks for a local root certificate file that most machines do not have, and does not fall back to the operating system trust store the way Node does. We tested that matrix across eight clients (psql, node-postgres, psycopg, pgx, Prisma, drizzle, and the rest) and published the exact versions and error strings in &lt;a href="https://layerbase.com/blog/postgres-sslmode-client-matrix" rel="noopener noreferrer"&gt;sslmode across real Postgres clients&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One trap worth knowing: if &lt;code&gt;sslmode&lt;/code&gt; appears in the URL, it takes precedence over an &lt;code&gt;ssl&lt;/code&gt; object you pass to the pool programmatically. If you configure TLS in code, remove the parameter from the string entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you run your own Postgres with a self-signed certificate
&lt;/h2&gt;

&lt;p&gt;The advice above assumes a publicly signed certificate. If you are pointing at your own box with a self-signed or private-authority certificate, note that node-postgres already fails on &lt;code&gt;sslmode=require&lt;/code&gt; today (it verifies, remember, so an untrusted certificate is rejected with &lt;code&gt;SELF_SIGNED_CERT_IN_CHAIN&lt;/code&gt;). You have two honest options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep verification and tell Node about your authority: &lt;code&gt;sslmode=verify-full&amp;amp;sslrootcert=/path/to/your-ca.crt&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Deliberately accept unverified TLS, for example inside a private network you control: &lt;code&gt;uselibpqcompat=true&amp;amp;sslmode=require&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What uselibpqcompat=true actually does
&lt;/h2&gt;

&lt;p&gt;The second suggestion in the warning text, &lt;code&gt;uselibpqcompat=true&amp;amp;sslmode=require&lt;/code&gt;, opts you into the future libpq behavior right now. Under it, &lt;code&gt;require&lt;/code&gt; means encrypt but do not verify the certificate, unless you also pass &lt;code&gt;sslrootcert&lt;/code&gt;, in which case the chain is verified against that authority and only the hostname check is skipped. It silences the warning because you have explicitly accepted the weaker promise rather than getting it by surprise.&lt;/p&gt;

&lt;p&gt;Only reach for it when that weaker promise is genuinely what you want, such as matching &lt;code&gt;psql&lt;/code&gt; behavior against a self-signed server. And know that the parameter is a node-postgres invention: it is not part of the Postgres protocol, and non-JavaScript clients will reject a connection string that contains it. Do not put it in a string that anything other than Node will read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ways people try to silence it that do not work
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;node --no-deprecation&lt;/code&gt; does nothing here. Despite reading like one, this is a plain process warning, not a deprecation warning.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;node --no-warnings&lt;/code&gt; (or the same flag in &lt;code&gt;NODE_OPTIONS&lt;/code&gt;) does silence it, along with every other warning your process might ever emit. That is a sledgehammer, and you lose real signals.&lt;/li&gt;
&lt;li&gt;There is no dedicated environment variable or option to turn off just this warning.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The connection string change is genuinely the answer. It is also the only response that does something useful: &lt;code&gt;--no-warnings&lt;/code&gt; hides the message, while &lt;code&gt;sslmode=verify-full&lt;/code&gt; makes your configuration say what it means, today and after v9.&lt;/p&gt;




&lt;p&gt;I run &lt;a href="https://layerbase.com" rel="noopener noreferrer"&gt;Layerbase&lt;/a&gt;, a managed Postgres (and the rest of a multi-engine catalog) where every connection string we emit already has TLS on. We still ship &lt;code&gt;sslmode=require&lt;/code&gt; on purpose, because of the client matrix above. If you want to see the string yourself, &lt;a href="https://layerbase.com/create/postgresql" rel="noopener noreferrer"&gt;create a Postgres&lt;/a&gt; and copy it. Swap &lt;code&gt;require&lt;/code&gt; for &lt;code&gt;verify-full&lt;/code&gt; in Node and the warning goes away.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>node</category>
      <category>javascript</category>
      <category>security</category>
    </item>
    <item>
      <title>Becoming the person that I needed when I started</title>
      <dc:creator>Bob Bass</dc:creator>
      <pubDate>Thu, 08 Jul 2021 20:49:47 +0000</pubDate>
      <link>https://dev.to/bobdotjs/becoming-the-person-that-i-needed-when-i-started-29jn</link>
      <guid>https://dev.to/bobdotjs/becoming-the-person-that-i-needed-when-i-started-29jn</guid>
      <description>&lt;p&gt;The title is confusing so allow me to elaborate. &lt;/p&gt;

&lt;p&gt;When I first started taking software development seriously, I didn't have a mentor. We could go into the psychology of why established developers aren't always friendly with beginners - but it doesn't seem necessary. It is a well-known punchline that Stack Overflow has discouraged more than a handful of beginners. &lt;/p&gt;

&lt;p&gt;When I was just getting started I would often pay a random experienced developer I found on LinkedIn somewhere in the ballpark of $100 to talk through a problem I was stuck on. The call might last 15 minutes or 45 minutes but needless to say I exhausted all of my options before doing this. &lt;/p&gt;

&lt;p&gt;Now that I am reasonably competent, it seems that I'm in a position to really make someone's day. I have found that if you tell a beginner that they are welcome to reach out to you if they get stuck, they almost always will. If you jump on a video call with a beginner, usually they are trying to accomplish something very simple. You may have spared 15 minutes out of your day to help a perfect stranger, but that 15 minutes may have prevented them from wasting a month moving in the wrong direction. &lt;/p&gt;

&lt;p&gt;I can provide a list of anecdotal information but instead I would like to encourage the more experienced developers to think back to a time when you were stuck on something very simple just because you were missing a small piece of context. Maybe you took a course and the instructor took something for granted that you really needed explained. &lt;/p&gt;

&lt;p&gt;I have found that the small investment of time that I put into helping the occasional stranger seems to have a disproportionately positive effect on them. No matter where I value my time, 15 minutes of it is an order of magnitude more valuable to a newbie developer than most other people.&lt;/p&gt;

&lt;p&gt;Of course there's a caveat to that. Time is your most valuable asset and you should never write that off. I also believe that what I'm proposing isn't completely selfless. &lt;/p&gt;

&lt;p&gt;When I help a beginner, I like to think that it's an opportunity for me to reinforce my knowledge, network, and make someone's day all at once. It feels good. &lt;/p&gt;

&lt;p&gt;I would like to encourage anyone who stumbles across this post to think back to a time when you've wasted days, weeks, or even months working on solving the wrong problem. Think back to the last time something clicked for you in a really meaningful way that caused you to start over instead of refactoring. &lt;/p&gt;

&lt;p&gt;I remember what I needed when I got started. I needed an experienced developer that was willing to spend 15 minutes talking through a problem with me. I needed someone with a real world perspective to gently nudge me back on track. I needed to feel like I wasn't massively inconveniencing every developer that I spoke with, simply because I was lacking some insight. &lt;/p&gt;

&lt;p&gt;I've been making an active effort to be that mentor to other people. It's not surprising that people are incredibly grateful when you spend a few minutes helping them out, the part that is however surprising - is that I think I've experienced more growth since I've opened myself up to do this.&lt;/p&gt;

&lt;p&gt;I hope to see the culture shift to be less critical towards the beginners who are often already uncomfortable asking for help. &lt;/p&gt;

</description>
      <category>leadership</category>
      <category>codenewbie</category>
      <category>culture</category>
    </item>
    <item>
      <title>Project NextApp (beta) needs some testers</title>
      <dc:creator>Bob Bass</dc:creator>
      <pubDate>Mon, 14 Dec 2020 11:52:21 +0000</pubDate>
      <link>https://dev.to/bobdotjs/project-nextapp-beta-needs-some-testers-3fm6</link>
      <guid>https://dev.to/bobdotjs/project-nextapp-beta-needs-some-testers-3fm6</guid>
      <description>&lt;p&gt;I built a platform that I think can be very useful for people to learn new technologies. To be honest, I built it for myself because I couldn't find it anywhere else. &lt;/p&gt;

&lt;p&gt;I have a theory that the best way to learn new technologies is by building a small project with it with no real expectations. It also helps when there's a community to consult with. &lt;/p&gt;

&lt;p&gt;2 years ago, I never thought I would be a professional developer. I started dabbling with software for my business and realized that I enjoyed it much more than I enjoyed owning a call center. I started building small projects and it was a very effective learning tool that greatly exceeded anything that tutorials did for me.&lt;/p&gt;

&lt;p&gt;I joined a startup accelerator to try to make a career out of my software. Now I'm at a crossroads where my software that I've been building long-term is getting really boring but I have a long waiting list and I can't abandon it. To prevent burnout, I've been looking for small weekly hackathons. Since I've been unable to find this. I created it. &lt;/p&gt;

&lt;p&gt;ProjectNext.app is a community built around learning new technologies with small projects. Let's say that I want to learn to publish an NPM package. maybe I want to set myself a goal of one week to publish an NPM package that initializes TypeScript inside of a legacy Vue.js app. I can set that as a vague goal and set it to 1 week. &lt;br&gt;
Over the course of that week, it might turn out that instead - I want to build an NPM package with a small standard library of functions that I always seem to be writing. I might modify the project to be a two-week project and update the goal. &lt;br&gt;
The Monday following the project completion date will have a community zoom call hosted where I get to present what I built, what I learned, and what I plan on doing next. Other people from the community can share their thoughts with me and maybe point me in the right direction if they see that I'm stuck on a concept. Maybe, during the call I share that my next project is going to be a GoLang REST API, and user123, also on the zoom call happens to be a go enthusiast. They might want to comment on a really good udemy class that they took and recommend. &lt;/p&gt;

&lt;p&gt;Meanwhile, you're building up a profile that will track which technologies you learn over time. It's possible that in the future I can use this platform to generate resumes. I would like for this platform to be incredibly friendly to beginners but also I wanted to be a rewarding experience for all of us developers who are constantly building side projects and learning new technologies. &lt;/p&gt;

&lt;p&gt;Learning new technologies is 50% of the "mission" while the other 50% is helping developers find a spark for a long-term idea by providing a framework to iterate quickly and get feedback until they find something more permanent. &lt;/p&gt;

&lt;p&gt;It integrates with GitHub so there's a lot of room for a lot of cool features down the line. &lt;/p&gt;

&lt;p&gt;ProjectNext.app was its own first submission. It was a proof of concept. I wanted to see if I could build it in one week and learn some new technologies in the process. I did. I also went through a life-changing career change at 30 years old and I want to help other developers do the same. I'm very interested in mentoring some beginners and I think that this platform can help me scout out some beginner developers who might need that added little bit of direction. &lt;/p&gt;

&lt;p&gt;The concept is very early on right now. I have a slack channel that is going to act as a hub for casual conversation until I build a community right into the website. &lt;br&gt;
I have a handful of other developers who are also in the same accelerator as myself who are also willing to help beginners, while simultaneously interested in using this platform to hone their own skills. &lt;/p&gt;

&lt;p&gt;A few features are missing right now. It doesn't yet have the ability to post and update. That functionality will be finished later this week. The same is true of following projects and creating teams. &lt;/p&gt;

&lt;p&gt;I have had at least two companies express interest in sponsoring prizes for small hackathons. It's something I'm definitely going to explore a bit deeper.&lt;/p&gt;

&lt;p&gt;I will share a proper release on here once all of the features are finished being implemented, but I would really love a few people to join early if only to help me replace some of the placeholder projects with real ones.&lt;/p&gt;

&lt;p&gt;If you are a beginner and you're interested in this, I'm also happy to provide a little bit of mentorship to help you get going if you choose to join. I'm looking for a community feedback to help me figure out a direction to take this thing. It's not aimed at becoming a viable business model, I'm just creating a platform that I personally wish existed and have been unable to find so far. &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%2Fi%2F1wun5zoab9rrgqs6jwhs.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%2Fi%2F1wun5zoab9rrgqs6jwhs.png" alt="Alt Text" width="800" height="1644"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Please let me know your thoughts. Does this sound interesting? Are you interested in being an early tester? What turns you off from this idea? What excites you about it? This whole thing is very new. I also recorded the process of building the app from start to finish and I'm going to host that video so that it's embedded on the project page. If it goes over well, I'm going to make that a permanent feature so that others can do the same thing. &lt;/p&gt;

&lt;p&gt;I'm looking forward to getting some feedback and helping some developers of all skill levels to level up their skills. &lt;/p&gt;

&lt;p&gt;Does ProjectNext.app have a place in the 2021 developer ecosystem? You tell me!&lt;/p&gt;

</description>
      <category>firstyearincode</category>
      <category>beginners</category>
      <category>help</category>
      <category>vue</category>
    </item>
    <item>
      <title>Leaving stability to bootstrap a SaaS startup</title>
      <dc:creator>Bob Bass</dc:creator>
      <pubDate>Fri, 04 Sep 2020 08:30:30 +0000</pubDate>
      <link>https://dev.to/bobdotjs/leaving-stability-to-bootstrap-a-saas-startup-175p</link>
      <guid>https://dev.to/bobdotjs/leaving-stability-to-bootstrap-a-saas-startup-175p</guid>
      <description>&lt;p&gt;I have been with some people may have referred to as a bottom feeder in the past. Almost 10 years ago I opened a debt collection agency and it has been fairly stable and moderately successful but I sold it in November of 2019 so that I could pursue automation full-time. &lt;/p&gt;

&lt;p&gt;I won't bore you with the details of how I ended up starting a small business automation agency but pivoting entirely to bootstrapping a startup aimed at creating cutting-edge software for the debt collection industry, but I will mention that over the years I created software for my agency using C# and .NET. Did that collection industry is notoriously 20 years behind the rest of the world when it comes to technology and I understand why that is. &lt;/p&gt;

&lt;p&gt;Nobody likes to be involved with the debt collection industry. There are bad actors who give the industry a bad name, consumer lawsuits are rampant, laws are unclear, outdated, and ambiguous, but most importantly - nobody has seen the need to fix a system that many people won't acknowledge is broken. &lt;/p&gt;

&lt;p&gt;If you are using dev.to, it's probably fair to assume that you are technologically inclined. That isn't the case for people in the debt collection world. Some agencies are processing $1 million monthly while using physical desk phones and Windows XP desktop. That isn't an exaggeration, it's not even an edge case. &lt;/p&gt;

&lt;p&gt;There has been one platform that tried to create a serverless debt collection platform (as opposed to the current standard of having a physical server in your office), and this company clearly didn't have experience collecting delinquent accounts. The software is verbose and complicated. it's very hard to be productive when the people who design the software don't really understand the industry. I've also noticed that the debt collection industry is very resistant to change and when I release my app, I have my doubts about whether or not people are going to give it a shot even though I will feel confident making the claim that their revenue should increase significantly if they are using a well-organized, reliable, and streamlined platform design specifically for their day-to-day operations. I am building the software that I had always wished existed when I ran my agency. &lt;/p&gt;

&lt;p&gt;When I started this project, I intended to build it using asp.net and my JavaScript skills were limited because I had spent most of my time and energy learning C#. I believe it was fortunate that my brother who was living in Silicon Valley at the time pushed me to dive into single-page applications with JavaScript. I brushed up on my JavaScript and learned to React pretty quickly but I didn't fall in love with it. I then built a small app with VueJS after watching The Honeypot.io documentary about Vue. It's fair to say that the MVC-like structure clicked with me in a way that I could only describe as 'common sense web design'. &lt;/p&gt;

&lt;p&gt;At the time I'm writing this, I have put in over 2,000 hours coding this platform. I have hired a part-time developer in Nigeria who is incredibly hard-working and skilled considering he's fresh out of a boot camp, and I have even relied on him to teach me best practices for team-based version control which I never had to learn since I've always built solo projects. &lt;/p&gt;

&lt;p&gt;At this point I've joined a startup accelerator and I really enjoy it. It helps keep me motivated and it keeps me hopeful about doors that it may open for me. All of this isn't without its downsides.&lt;/p&gt;

&lt;p&gt;The money that I made selling my agency has been almost completely exhausted covering development expenses such as server hosting and developer commissions. I'm technically unemployed since I choose to focus on this startup which is not yet making money and I'm putting a lot of faith into the idea that I know this industry very intimately and that my platform will be groundbreaking enough that people will be interested in trying it out. &lt;/p&gt;

&lt;p&gt;So - for better or worse, I've decided to sell my house to provide me with additional runway. The papers are signed and the sale will go through any day now so there's no turning back. I'm passionate about this and I believe in myself. &lt;/p&gt;

&lt;p&gt;The worst case scenario is that the app crashes and burns which I don't think is incredibly likely but if that happens, at least I will have gained invaluable experience building out an entire CRM and automation platform from scratch. I've learned Node, Vue, Firebase/Firestore/Cloud Functions, MySQL architecture, custom API integration, and version control in a team setting.&lt;/p&gt;

&lt;p&gt;Hypothetically, if this startup fails - I'm not leaving empty-handed. I've learned a valuable lesson about how passionate I am when it comes to software development and I would feel confident joining a remote team working with a similar stack. I've also proven to myself that I can learn and implement new skills quickly and efficiently. I'm much more competent than I was when I started down this path and I even find myself refactoring old code to prevent technical debt further ahead into the future. &lt;/p&gt;

&lt;p&gt;There's a lot of uncertainty right now, much of it stems back to my inexperience with marketing and sales, but I'm determined to push on and I will be sure to update here once there are some meaningful developments. I would be lying if I said that I wasn't terrified, or if I didn't admit that I feel like I'm making reckless decisions. &lt;/p&gt;

&lt;p&gt;Today is my 30th birthday and I don't have the stability that I've gotten so used to over the past decade because I'm risking quite a bit to pursue a dream which has long been on the back burner. &lt;/p&gt;

&lt;p&gt;I hope that someone who may be interested in doing the same can possibly gain some perspective from my experience so far, likewise - I would be thrilled to get some feedback from anybody who's been through a major career pivot from something stable to something uncertain for the sake of passion. &lt;/p&gt;

&lt;p&gt;As I've done for quite a long time now, I will continue to share my experiences as I go down this reckless path of entrepreneurship. &lt;/p&gt;

</description>
      <category>startup</category>
      <category>career</category>
      <category>vue</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
