<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Sql on Murat Useinov</title>
    <link>https://useinov.com/tags/sql/</link>
    <description>Recent content in Sql on Murat Useinov</description>
    <generator>Hugo</generator>
    <language>en-us</language>
    <lastBuildDate>Wed, 08 Jul 2026 21:45:00 +0300</lastBuildDate>
    <atom:link href="https://useinov.com/tags/sql/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Testing PostgreSQL 19 beta without guessing</title>
      <link>https://useinov.com/posts/2026/testing-postgres-beta/</link>
      <pubDate>Wed, 08 Jul 2026 21:45:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2026/testing-postgres-beta/</guid>
      <description>&lt;p&gt;A production-like dump, a separate PostgreSQL 19 beta instance, one evening plus machine time. That is the whole method, and it is the only thing a database beta is for: finding out where the new version changes the behavior of your application while the change is still a bug report and not your incident.&lt;/p&gt;
&lt;p&gt;Restore the dump first. Data size matters. A plan that is fine on a thousand rows goes bad on fifty million.&lt;/p&gt;</description>
    </item>
    <item>
      <title>MySQL 9.7 LTS is an operational choice</title>
      <link>https://useinov.com/posts/2026/mysql-97-lts/</link>
      <pubDate>Fri, 10 Apr 2026 20:10:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2026/mysql-97-lts/</guid>
      <description>&lt;p&gt;An SQL mode set in 2019 that nobody remembers why. An authentication plugin the driver happened to support. A charset and collation nobody chose. That is what a MySQL upgrade is made of. MySQL 9.7 LTS is out, so I am making the list again.&lt;/p&gt;
&lt;p&gt;An LTS release of a database is a date more than a feature list. The date until which somebody else worries about patches, and the date after which the worry is yours. For a working PHP project the question is not &amp;ldquo;what is new&amp;rdquo;. It is &amp;ldquo;what did we assume&amp;rdquo;.&lt;/p&gt;</description>
    </item>
    <item>
      <title>PostgreSQL 18, the upgrade notes</title>
      <link>https://useinov.com/posts/2025/postgres-18-upgrade/</link>
      <pubDate>Fri, 26 Sep 2025 22:45:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2025/postgres-18-upgrade/</guid>
      <description>&lt;p&gt;PostgreSQL 18 went GA yesterday. I ran the beta in July. Now it is not a preview, it is a ticket, and these are the notes from planning it.&lt;/p&gt;
&lt;p&gt;The quiet feature that matters most for the upgrade itself: &lt;code&gt;pg_upgrade&lt;/code&gt; now carries planner statistics over. Before, the minutes or hours after switching versions were the scary part. Fresh cluster, empty statistics, the planner guessing, production limping until ANALYZE finished. That cliff is mostly gone. This alone changes how a major upgrade feels at 2 a.m.&lt;/p&gt;</description>
    </item>
    <item>
      <title>A weekend with PostgreSQL 18 beta</title>
      <link>https://useinov.com/posts/2025/postgres-18-beta/</link>
      <pubDate>Wed, 02 Jul 2025 18:50:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2025/postgres-18-beta/</guid>
      <description>&lt;p&gt;A staging copy of one database, restored over the weekend onto PostgreSQL 18 beta. GA is expected in autumn. Two things I wanted to touch: asynchronous I/O and the built-in &lt;code&gt;uuidv7()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Start with uuidv7, because it is about a mistake many of us already made, me included. Random UUIDv4 primary keys scatter inserts across the whole index. Every insert lands on a random page, the working set is the entire index, buffers churn. UUIDv7 is time-ordered: new rows go to the same few pages, like a sequence, but still globally unique.&lt;/p&gt;</description>
    </item>
    <item>
      <title>PostgreSQL 17 and why VACUUM is an API concern</title>
      <link>https://useinov.com/posts/2024/postgres-vacuum/</link>
      <pubDate>Fri, 27 Sep 2024 15:20:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2024/postgres-vacuum/</guid>
      <description>&lt;p&gt;A reporting transaction open for hours, and &lt;code&gt;n_dead_tup&lt;/code&gt; on the hot table climbing all day while autovacuum ran non-stop and changed nothing.&lt;/p&gt;
&lt;p&gt;That was one project. PostgreSQL 17 came out yesterday, and the headline work is exactly this plumbing: vacuum got a new memory structure for tracking dead tuples, far less memory and no longer capped the old way, plus better throughput for high-concurrency writes and bulk loads. Maintenance internals. Which is why backend people should care.&lt;/p&gt;</description>
    </item>
    <item>
      <title>AI assistants do not know your production</title>
      <link>https://useinov.com/posts/2023/ai-assistants-limits/</link>
      <pubDate>Sat, 23 Dec 2023 10:40:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2023/ai-assistants-limits/</guid>
      <description>&lt;p&gt;A DTO with a dozen fields, a framework migration, a slow query incident. Three tasks from the last few months, all done with Copilot and ChatGPT open. Here is where the line runs for me.&lt;/p&gt;
&lt;p&gt;Task one, boilerplate. The DTO, its validation rules, a test with fixtures. The assistant writes this faster than I type, and the result needs only a quick read. Same for converting an array-shaped legacy structure into typed classes. This is honest time saved, maybe an hour a day. The code is the kind where being generic is correct.&lt;/p&gt;</description>
    </item>
    <item>
      <title>PostgreSQL 16, read through one workload</title>
      <link>https://useinov.com/posts/2023/postgres-sixteen/</link>
      <pubDate>Sat, 16 Sep 2023 20:25:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2023/postgres-sixteen/</guid>
      <description>&lt;p&gt;A nightly import, a few hundred million rows through &lt;code&gt;COPY&lt;/code&gt;, then reporting queries in the morning. That is the only workload I care about, so that is how I read the PostgreSQL 16 release notes: with one pipeline in mind and everything else skipped.&lt;/p&gt;
&lt;p&gt;Three items survived the filter.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;COPY&lt;/code&gt; got faster, noticeably when several loads run at once. Our import is exactly this shape, parallel COPY streams into partitioned tables. I will take the free speed. But the old lesson stays: if your import still does row-by-row INSERT from PHP, no Postgres release will save you. Batch into COPY first, then talk about versions.&lt;/p&gt;</description>
    </item>
    <item>
      <title>UUID primary keys and the B-tree bill</title>
      <link>https://useinov.com/posts/2023/uuid-primary-keys/</link>
      <pubDate>Thu, 03 Aug 2023 21:35:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2023/uuid-primary-keys/</guid>
      <description>&lt;p&gt;The same table, the same rows, about 1.5 times the disk. That was the measurement after someone made UUIDv4 the primary key in InnoDB, and that is before counting secondary indexes.&lt;/p&gt;
&lt;p&gt;Every new API wants UUIDs. Distributed generation, no coordination, nothing leaks about row counts. All true. Then the database quietly starts paying.&lt;/p&gt;
&lt;p&gt;InnoDB clusters the table by primary key. Auto-increment inserts always land in the rightmost page, warm and predictable. Random UUIDs land anywhere in the tree: page splits everywhere, half-empty pages, and the set of hot pages becomes the whole index. Secondary indexes store the primary key in every entry, so each of them inherits the full 16 bytes too. In PostgreSQL the heap does not cluster, so the pain is smaller, but index bloat and lost locality are still there.&lt;/p&gt;</description>
    </item>
    <item>
      <title>Outbox: events that do not lie</title>
      <link>https://useinov.com/posts/2023/transactional-outbox/</link>
      <pubDate>Wed, 07 Jun 2023 20:40:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2023/transactional-outbox/</guid>
      <description>&lt;p&gt;An email confirming an order that does not exist. A colleague showed me this one last week, together with the mirror bug: an order that exists and never got its email.&lt;/p&gt;
&lt;p&gt;The code was the obvious kind. Commit the Doctrine transaction, then dispatch to the AMQP transport. Two systems, two writes, no shared transaction. Publish after commit and the process can die between them, event lost. Publish inside the transaction and the broker gets an event for data that may still roll back. Both orders are wrong, and under real load this fires weekly.&lt;/p&gt;</description>
    </item>
    <item>
      <title>MERGE arrives in PostgreSQL 15</title>
      <link>https://useinov.com/posts/2022/postgres-merge/</link>
      <pubDate>Thu, 27 Oct 2022 20:20:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2022/postgres-merge/</guid>
      <description>&lt;p&gt;A supplier price list, loaded into &lt;code&gt;staging_prices&lt;/code&gt;, then reconciled against &lt;code&gt;prices&lt;/code&gt;. Since PostgreSQL 15 came out two weeks ago, that is one statement:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;n&#34;&gt;MERGE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;INTO&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;prices&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;p&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;USING&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;staging_prices&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;s&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ON&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;p&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;sku&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;s&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;sku&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHEN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;MATCHED&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AND&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;s&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;price&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;IS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;THEN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;DELETE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHEN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;MATCHED&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AND&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;p&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;price&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;s&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;price&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;THEN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;UPDATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;SET&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;price&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;s&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;price&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;updated_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;now&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;()&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHEN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;MATCHED&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;THEN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;INSERT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;sku&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;price&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;VALUES&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;s&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;sku&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;s&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;price&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Before 15 this was three statements in a transaction, or a stored procedure, or a loop in PHP. Now it is one statement that says what it does. Update changed rows, delete withdrawn ones, insert new ones. People coming from Oracle and SQL Server waited a decade for this. The conditional &lt;code&gt;WHEN MATCHED AND ...&lt;/code&gt; branches are the real value. &lt;code&gt;ON CONFLICT&lt;/code&gt; cannot express &amp;ldquo;delete when the source says so&amp;rdquo; at all.&lt;/p&gt;</description>
    </item>
    <item>
      <title>JSONB or a normal table</title>
      <link>https://useinov.com/posts/2022/jsonb-or-columns/</link>
      <pubDate>Sat, 16 Jul 2022 10:05:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2022/jsonb-or-columns/</guid>
      <description>&lt;p&gt;Audit snapshots, product attributes, and a document with two hot keys. Three cases from one Postgres project, one question for each: column or JSONB.&lt;/p&gt;
&lt;p&gt;I answer it by looking at queries, not at data.&lt;/p&gt;
&lt;p&gt;Audit snapshots. When an order changes, we store the whole previous state. Nobody queries inside it. It is read as a blob, whole, rarely, by a human during an incident. Perfect JSONB. Normalizing it would mean a dozen tables for data nobody joins.&lt;/p&gt;</description>
    </item>
    <item>
      <title>Backfill in batches, not in one UPDATE</title>
      <link>https://useinov.com/posts/2022/batched-backfill/</link>
      <pubDate>Thu, 17 Mar 2022 22:40:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2022/batched-backfill/</guid>
      <description>&lt;p&gt;Sixty million rows in &lt;code&gt;orders&lt;/code&gt;, a new nullable column &lt;code&gt;total_cents&lt;/code&gt; from expand-and-contract, and a Friday afternoon. The question is how to fill it.&lt;/p&gt;
&lt;p&gt;The naive answer is one statement. &lt;code&gt;UPDATE orders SET total_cents = ROUND(total * 100)&lt;/code&gt;. On a test database it works. On production it holds row locks on the whole table for the whole run, writes one giant chunk of WAL, and the replica falls minutes behind. On MySQL the binlog gets the same present. One project I worked on learned this on that Friday. Since then I backfill only in batches.&lt;/p&gt;</description>
    </item>
    <item>
      <title>Stop building running totals in PHP</title>
      <link>https://useinov.com/posts/2020/window-functions-mysql/</link>
      <pubDate>Tue, 06 Oct 2020 15:40:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2020/window-functions-mysql/</guid>
      <description>&lt;p&gt;Two hundred thousand rows over the wire to compute forty numbers. That was our monthly report on MySQL 5.7: fetch all transactions for the period, loop in PHP, accumulate a running balance, compare each row with the previous one, rank customers by volume. Classic 5.7 shape, because the database could not say &amp;ldquo;previous row&amp;rdquo; or &amp;ldquo;rank within group&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;We are finally moving that project to MySQL 8. Two years after GA, which by database standards is reckless haste. The first win had nothing to do with performance. We deleted PHP.&lt;/p&gt;</description>
    </item>
    <item>
      <title>A deadlock is the database doing its job</title>
      <link>https://useinov.com/posts/2020/deadlock-not-bug/</link>
      <pubDate>Sat, 02 May 2020 20:30:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2020/deadlock-not-bug/</guid>
      <description>&lt;p&gt;Error 1213 in the logs, and the chat lights up: the database is broken. It is not. Two transactions locked rows in opposite order, A waits for B, B waits for A, and InnoDB did the only sane thing: picked a victim and killed it. The cycle is gone. This is a feature.&lt;/p&gt;
&lt;p&gt;The classic shape is a money transfer. One request moves funds from account 1 to account 2, another from 2 to 1, both update the first account and then the second. Opposite order, instant cycle under load. Tests never show it, because tests do not run two of these in the same millisecond.&lt;/p&gt;</description>
    </item>
    <item>
      <title>Cursor pagination is part of your API contract</title>
      <link>https://useinov.com/posts/2020/cursor-pagination-contract/</link>
      <pubDate>Wed, 08 Jan 2020 21:30:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2020/cursor-pagination-contract/</guid>
      <description>&lt;p&gt;Someone inserts a row while the client walks the pages, and the whole window shifts. Page two shows an item the client already saw on page one. Or an item falls between pages and the client never sees it. For a feed this is annoying. For an export or a sync endpoint this is a data loss bug that nobody can reproduce.&lt;/p&gt;
&lt;p&gt;I used to think keyset pagination is a performance trick. Deep OFFSET is slow, keyset is fast, end of story. Now I think the performance part is the boring half. Offset pagination over a changing dataset lies to the client. That is the interesting half.&lt;/p&gt;</description>
    </item>
    <item>
      <title>PostgreSQL 12 inlines your CTE</title>
      <link>https://useinov.com/posts/2019/postgres12-cte/</link>
      <pubDate>Fri, 18 Oct 2019 21:45:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2019/postgres12-cte/</guid>
      <description>&lt;p&gt;&lt;code&gt;grep -rn &amp;quot;WITH &amp;quot; src/&lt;/code&gt; was the first thing I ran after upgrading to PostgreSQL 12 this month. I was looking for a trick that had stopped working.&lt;/p&gt;
&lt;p&gt;For years &lt;code&gt;WITH&lt;/code&gt; was an optimization fence. The planner materialized the CTE first and only then ran the outer query. Everyone used this both ways. As a bug: you wrap a subquery into a CTE for readability, the planner stops pushing conditions inside, a fast query becomes a scan of half a table. As a feature: you write a CTE on purpose, to pin evaluation order and stop the planner from being creative. Half of the CTE advice on the internet is really advice about the fence.&lt;/p&gt;</description>
    </item>
    <item>
      <title>N&#43;1 hides on your laptop</title>
      <link>https://useinov.com/posts/2019/n-plus-one-production/</link>
      <pubDate>Fri, 05 Jul 2019 22:35:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2019/n-plus-one-production/</guid>
      <description>&lt;p&gt;Ten rows in the dev database, on the same machine, 0.1 ms per query. Ten extra queries is one millisecond. The page feels instant, the code ships. Production has a thousand rows and the database one network hop away. Round trip is about a millisecond even in a good datacenter. A thousand queries is a second of pure network waiting. Not slow SQL. Each query is fast. The plural is slow.&lt;/p&gt;</description>
    </item>
    <item>
      <title>Transactional DDL is not portable</title>
      <link>https://useinov.com/posts/2019/transactional-ddl/</link>
      <pubDate>Mon, 18 Mar 2019 22:45:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2019/transactional-ddl/</guid>
      <description>&lt;p&gt;Statement three of a migration fails. Column name typo. On PostgreSQL the first two ALTERs roll back with it, the schema returns to the exact state before the migration, you fix the typo and run again. On MySQL the first two ALTERs are already permanent.&lt;/p&gt;
&lt;p&gt;Same &lt;code&gt;up()&lt;/code&gt; method, same &lt;code&gt;php artisan migrate&lt;/code&gt;, same green output. The migration tool gives one abstraction over two very different databases. The abstraction covers syntax. It does not cover what happens on failure.&lt;/p&gt;</description>
    </item>
    <item>
      <title>PostgreSQL 11: partitions grow up, JIT arrives</title>
      <link>https://useinov.com/posts/2018/postgres-eleven-jit/</link>
      <pubDate>Fri, 26 Oct 2018 19:10:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2018/postgres-eleven-jit/</guid>
      <description>&lt;p&gt;&lt;code&gt;jit = on&lt;/code&gt;, restart, run the API test suite. Same numbers as before. That was my first evening with PostgreSQL 11, released last week, and it was the correct result.&lt;/p&gt;
&lt;p&gt;JIT is the loud feature of this release and the misunderstood one. Postgres can now compile expression evaluation into machine code through LLVM. People read &amp;ldquo;compilation&amp;rdquo; and expect their endpoints to get faster. They will not. A primary key lookup takes a fraction of a millisecond. There is nothing in it worth compiling, and the compilation itself costs more than the whole query. JIT is for the other kind of query: an aggregate chewing through millions of rows, where the same expression runs so many times that generating machine code for it pays back.&lt;/p&gt;</description>
    </item>
    <item>
      <title>Covering index and the price of SELECT *</title>
      <link>https://useinov.com/posts/2018/covering-index/</link>
      <pubDate>Sun, 15 Jul 2018 12:35:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2018/covering-index/</guid>
      <description>&lt;p&gt;&lt;code&gt;Using index&lt;/code&gt; in the Extra column of EXPLAIN. That is the cheapest read MySQL can do, and you often get it almost for free.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;user_id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;user_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;42&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;ORDER&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;BY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;DESC&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;LIMIT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;20&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With an index on &lt;code&gt;(user_id, created_at)&lt;/code&gt; everything the query needs is in the index leaves. The table is never touched. A composite index finds rows fast, a covering index answers the query on its own. Postgres calls this Index Only Scan, with one condition: the visibility map must be fresh, so a table that vacuum never visits quietly falls back to heap fetches.&lt;/p&gt;</description>
    </item>
    <item>
      <title>MySQL 8.0: finally, modern SQL</title>
      <link>https://useinov.com/posts/2018/mysql-eight-sql/</link>
      <pubDate>Tue, 24 Apr 2018 22:15:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2018/mysql-eight-sql/</guid>
      <description>&lt;p&gt;&lt;code&gt;@row := @row + 1&lt;/code&gt;. I have a report in production that stands on that trick, and the trick was never guaranteed to work. Evaluation order of user variables in SELECT is undefined, it just happened to behave. MySQL 8.0 went GA last week, and for me the release is about SQL. Window functions and CTEs, the things Postgres people stopped noticing years ago, are here.&lt;/p&gt;
&lt;p&gt;The classic task: top three orders per customer. In 5.7 you had a self-join nobody could read a month later, or the variable trick. Now:&lt;/p&gt;</description>
    </item>
    <item>
      <title>Keyset pagination instead of OFFSET</title>
      <link>https://useinov.com/posts/2017/keyset-pagination/</link>
      <pubDate>Mon, 19 Jun 2017 10:20:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2017/keyset-pagination/</guid>
      <description>&lt;p&gt;&lt;code&gt;LIMIT 50 OFFSET 500000&lt;/code&gt;. Page 10001 of an events table, from a paginator someone wrote in an afternoon. Postgres has no shortcut to row 500000. It walks the index through half a million entries, fetches them, throws them away, and returns fifty. Page one is fast. Page ten thousand is slow, and every page after it is slower. Run EXPLAIN ANALYZE on both: the plans are identical, the numbers are not.&lt;/p&gt;</description>
    </item>
    <item>
      <title>Parallel query in PostgreSQL 9.6</title>
      <link>https://useinov.com/posts/2016/postgres-parallel-query/</link>
      <pubDate>Fri, 30 Sep 2016 22:10:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2016/postgres-parallel-query/</guid>
      <description>&lt;p&gt;38 seconds to 11. Same query, same forty million rows, no index added, no SQL changed. The only difference is PostgreSQL 9.6, released yesterday, and one setting.&lt;/p&gt;
&lt;p&gt;The query is a typical report: count and sum over an events table, grouped by day, three months of data. On 9.5 the plan is one process grinding through the table. On 9.6 with parallelism enabled:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;Finalize HashAggregate
  -&amp;gt;  Gather
        Workers Planned: 4
        -&amp;gt;  Partial HashAggregate
              -&amp;gt;  Parallel Seq Scan on events
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Four workers scan their own chunks, aggregate partially, the leader merges. Just more hands. For years we optimized SQL as if the database had exactly one worker per query. That assumption expired yesterday.&lt;/p&gt;</description>
    </item>
    <item>
      <title>Column order in a composite index</title>
      <link>https://useinov.com/posts/2016/composite-index-order/</link>
      <pubDate>Mon, 04 Jul 2016 23:25:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2016/composite-index-order/</guid>
      <description>&lt;p&gt;Fifty rows. Three indexes on the table, one per column. Still slow. The query is the standard one from any multi-tenant application:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;*&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;tenant_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;?&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AND&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;status&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;?&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;ORDER&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;BY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;DESC&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;LIMIT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;50&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&amp;ldquo;Add an index&amp;rdquo; is the typical reaction, and one index on each column is the typical result. The number of indexes is not the point. The order of columns inside one index is.&lt;/p&gt;</description>
    </item>
    <item>
      <title>Upsert in PostgreSQL 9.5</title>
      <link>https://useinov.com/posts/2016/postgres-upsert/</link>
      <pubDate>Tue, 12 Jan 2016 22:15:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2016/postgres-upsert/</guid>
      <description>&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-php&#34; data-lang=&#34;php&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;nv&#34;&gt;$row&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;nv&#34;&gt;$db&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&#34;na&#34;&gt;fetchOne&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;SELECT id FROM counters WHERE name = ?&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;k&#34;&gt;array&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt;$name&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;if&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt;$row&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;nv&#34;&gt;$db&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&#34;na&#34;&gt;execute&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;UPDATE counters SET value = value + 1 WHERE name = ?&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;k&#34;&gt;array&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt;$name&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt; &lt;span class=&#34;k&#34;&gt;else&lt;/span&gt; &lt;span class=&#34;p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;nv&#34;&gt;$db&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&#34;na&#34;&gt;execute&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;INSERT INTO counters (name, value) VALUES (?, 1)&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;k&#34;&gt;array&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;nv&#34;&gt;$name&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Works on the laptop. In production two requests arrive in the same millisecond. Both SELECT, both see nothing, both INSERT. One dies with a duplicate key error. Or worse, there is no unique constraint, and now you have two rows and a bug report you cannot reproduce. The window between SELECT and INSERT is tiny, so it fires once a week, always for someone else.&lt;/p&gt;</description>
    </item>
    <item>
      <title>JSON column is not a schema</title>
      <link>https://useinov.com/posts/2015/json-not-schema/</link>
      <pubDate>Fri, 23 Oct 2015 22:40:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2015/json-not-schema/</guid>
      <description>&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;ALTER&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;users&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ADD&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;profile&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;JSON&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;MySQL 5.7 went GA this week, and this line is now legal. Native JSON type: validation on insert, binary storage, functions to read paths. The question is not whether it works. It is what belongs in it.&lt;/p&gt;
&lt;p&gt;Classic case: user profile with flexible metadata. Marketing wants a new field every other week. The old options were bad in familiar ways. Forty nullable columns and an ALTER for every idea. Or EAV, key-value rows, where every read is a self-join festival and nothing has a type.&lt;/p&gt;</description>
    </item>
    <item>
      <title>Kohana ORM and my first real N&#43;1</title>
      <link>https://useinov.com/posts/2014/kohana-orm-n-plus-one/</link>
      <pubDate>Wed, 12 Nov 2014 20:15:00 +0300</pubDate>
      <guid>https://useinov.com/posts/2014/kohana-orm-n-plus-one/</guid>
      <description>&lt;p&gt;Fifty one queries. That is what the SQL panel of the Kohana profiler showed for one catalog page. Fifty posts on the page.&lt;/p&gt;
&lt;p&gt;Nothing changed in the code. The page took two seconds because there was more data than in spring, and the code was written for spring.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;$post-&amp;gt;author-&amp;gt;name&lt;/code&gt; in a loop. One query for the list of posts, then one more for the author of every post, inside the &lt;code&gt;foreach&lt;/code&gt;. The line looks innocent, and that is the problem: lazy loading hides the price. You write the relation, you go home early, and the bill comes in six months.&lt;/p&gt;</description>
    </item>
  </channel>
</rss>
