<?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: Matty Stratton</title>
    <description>The latest articles on DEV Community by Matty Stratton (@mattstratton).</description>
    <link>https://dev.to/mattstratton</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%2F38834%2F68a98062-d7f1-4797-8a79-a34d8d217a27.jpg</url>
      <title>DEV Community: Matty Stratton</title>
      <link>https://dev.to/mattstratton</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mattstratton"/>
    <language>en</language>
    <item>
      <title>Don't Give the Model SQL</title>
      <dc:creator>Matty Stratton</dc:creator>
      <pubDate>Mon, 17 Aug 2026 14:20:00 +0000</pubDate>
      <link>https://dev.to/mattstratton/dont-give-the-model-sql-5h32</link>
      <guid>https://dev.to/mattstratton/dont-give-the-model-sql-5h32</guid>
      <description>&lt;p&gt;I built a web app to answer questions about my own health data, for a reason that is embarrassingly small. I already had a perfectly good way to ask them: a Claude Code skill that queried the database and reasoned over the results. It worked well. It also can't run on Claude iOS, and the moment I actually want to ask "should I be worried about this" is standing in a kitchen at 6am, not sitting at a desk.&lt;/p&gt;

&lt;p&gt;So it lives at a URL, behind Google sign-in allowlisted to exactly one address, with the &lt;a href="https://github.com/mattstratton/mattstratton-fitness-tracker" rel="noopener noreferrer"&gt;source public&lt;/a&gt; minus the data. It reads the TimescaleDB database I wrote about in &lt;a href="https://dev.to/mattstratton/the-past-keeps-changing-235g"&gt;The Past Keeps Changing&lt;/a&gt;, and it answers two different kinds of question in two deliberately different ways.&lt;/p&gt;

&lt;p&gt;(Obligatory: none of this is medical advice, and the thresholds in it are tuned to one person's circumstances. If you copy the rules you will get advice calibrated to somebody else's body.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Two modes, kept apart on purpose
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The coach view is deterministic.&lt;/strong&gt; Protein adherence, deficit versus the scale, weight trend, overreaching, stalled lifts, data freshness. Each one is a pure function in &lt;code&gt;lib/signals/&lt;/code&gt;, unit-tested against fixtures, no LLM involved anywhere. A verdict traces to a rule instead of to a vibe, and &lt;code&gt;unknown&lt;/code&gt; is a first-class result distinct from &lt;code&gt;ok&lt;/code&gt;, which matters because this dataset produces &lt;code&gt;unknown&lt;/code&gt; constantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ask view is an LLM.&lt;/strong&gt; Claude, server-side, over thirteen read-only tools. It handles the questions nobody wrote a rule for: am I stalling on squat, how big is my deficit actually, what's my VO2max doing.&lt;/p&gt;

&lt;p&gt;The interesting design work is all in the second one, and it's mostly about what I refused to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious design
&lt;/h2&gt;

&lt;p&gt;Hand the model SQL. Give it the schema, a read-only connection, and let it write queries. It's the design every "chat with your database" demo uses, it takes an afternoon, and for a database this small the query cost is irrelevant.&lt;/p&gt;

&lt;p&gt;I rejected it, because there are six traps in this data and every one of them has already produced a wrong answer in this project, on a page I was looking at, at least once.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Today is a Partial Day.&lt;/strong&gt; Its numbers are still accumulating. Compare it against a completed day and you invent a fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A gap is not a zero.&lt;/strong&gt; An unlogged day is unlogged, not a day I ate nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apple shadow-copies every Liftosaur session.&lt;/strong&gt; Query the workouts view naively and training volume roughly doubles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;energy_balance&lt;/code&gt; overstates the deficit by about 2.7x.&lt;/strong&gt; Over the last 30 complete days it reports an average intake of 1,602 kcal against 3,216 burned, a net of −1,614 a day, which predicts losing 3.2 lb a week. The scale over the same window says 1.2. Basal energy is a formula estimate from weight, height and age, and watch-measured active energy runs generous. Both are real numbers whose &lt;em&gt;difference&lt;/em&gt; is not a measurement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single weigh-in is noise.&lt;/strong&gt; Day-to-day scale movement is water.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;reps: 0&lt;/code&gt; is a set that was attempted and failed&lt;/strong&gt;, not a set that's missing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Given SQL, a model walks into all six, which says nothing about the model and everything about the schema. The traps are invisible from it. Nothing about a column called &lt;code&gt;calories&lt;/code&gt; tells you today's value is half-formed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the prompt isn't the fix
&lt;/h2&gt;

&lt;p&gt;I could put all six traps in the system prompt, and if I do, the model gets them right &lt;em&gt;most of the time&lt;/em&gt;. That's what decided the design, because most of the time is the worse outcome.&lt;/p&gt;

&lt;p&gt;A tool that's wrong every time gets caught on day one and thrown away. A tool that's right ninety-something percent of the time gets trusted, and then the rare wrong answer arrives wearing exactly the same confident formatting as the right ones. I have no way to spot it, because the whole reason I'm asking is that I don't already know the answer.&lt;/p&gt;

&lt;p&gt;So the traps are foreclosed by the shape of the tools rather than by instructions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Windowed queries end in &lt;code&gt;AND observed_on &amp;lt; today_local()&lt;/code&gt;. The Partial Day isn't excluded by the model remembering to exclude it. It isn't reachable.&lt;/li&gt;
&lt;li&gt;Gaps stay absent rows. Nothing zero-fills, so there's no zero to misread.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No tool reaches Apple's workout view at all.&lt;/strong&gt; The double-counting trap isn't documented for the model's benefit, it's unreachable through the API surface.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;energy_balance&lt;/code&gt; cannot be fetched without its reality check arriving in the same payload. You can't get the misleading number on its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The prompt still describes all six traps, because a model that understands &lt;em&gt;why&lt;/em&gt; a window ends where it does gives better answers than one that just gets truncated data. But the prompt is not what's holding them. If the prompt were deleted tomorrow the answers would get worse and they wouldn't get wrong in those six specific ways.&lt;/p&gt;

&lt;p&gt;There's also no write tool, and I mean that literally rather than as shorthand for one that's disabled or gated behind a confirmation. There is nothing to disable. Program changes stay in Liftosaur and macro targets stay MacroFactor's call, so the chat has no legitimate reason to mutate anything. The reasoning is in &lt;a href="https://github.com/mattstratton/mattstratton-fitness-tracker/blob/main/docs/adr/0006-typed-tools-not-sql-for-the-chat.md" rel="noopener noreferrer"&gt;ADR 0006&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catalog that wasn't the index
&lt;/h2&gt;

&lt;p&gt;Then I nearly shipped a tool that was wrong in a way none of that structure protects against.&lt;/p&gt;

&lt;p&gt;The chat needs to know which metrics exist. There's a &lt;code&gt;metric_catalog&lt;/code&gt; table holding canonical units and an attention grade per metric, so &lt;code&gt;list_metrics&lt;/code&gt; obviously reads from that. It compiled. It typechecked. It returned rows that looked entirely plausible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;in_data  catalogued  uncatalogued
81       38          43
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catalog covers 38 of 81 metrics. The other 43 aren't stale junk: sorted by coverage they start with walking and running distance (3,865 days of it, updated today), flights climbed, walking heart rate average, walking speed, step length, plus the entire micronutrient panel, all of it current because MacroFactor logs micros.&lt;/p&gt;

&lt;p&gt;So the tool would have worked exactly as written and made the chat answer "I don't have that" about ten years of walking distance sitting right there in the database.&lt;/p&gt;

&lt;p&gt;No test would have caught this. The function did what it said. I caught it by printing &lt;code&gt;.length&lt;/code&gt; from a throwaway script against the real database and noticing 38 where I expected 81.&lt;/p&gt;

&lt;p&gt;The fix inverts the join. Drive from &lt;code&gt;observations_daily&lt;/code&gt;, &lt;code&gt;LEFT JOIN&lt;/code&gt; the catalog, and an uncatalogued metric shows up with &lt;code&gt;catalogued: false&lt;/code&gt; and a null unit rather than not showing up. It costs one aggregate over the whole continuous aggregate per call, which &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; puts comfortably under 100ms, and that's fine for something called once a conversation.&lt;/p&gt;

&lt;p&gt;The general shape: &lt;strong&gt;a lookup table is not an index of reality unless something enforces that it is.&lt;/strong&gt; Nothing did. Nothing does now either, but at least the absence is visible in the output.&lt;/p&gt;

&lt;h2&gt;
  
  
  False precision is a real cost
&lt;/h2&gt;

&lt;p&gt;Raw rows out of the views look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;observed&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;  &lt;span class="k"&gt;calories&lt;/span&gt;            &lt;span class="k"&gt;protein&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt;
&lt;span class="ld"&gt;2026-08-08&lt;/span&gt;   &lt;span class="mf"&gt;1370.9033333354562&lt;/span&gt;  &lt;span class="mf"&gt;187.9353333412348&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Summing floats does that. Two problems once it reaches a language model. It's false precision, because a scale that reports to a fifth of a pound did not measure thirteen decimal places, and an answer quoting them implies rigour the data doesn't have. And it's pure token cost: eighteen characters where six will do, on every point of every series.&lt;/p&gt;

&lt;p&gt;Rounded to two decimals at the tool boundary rather than in the query layer, on the principle that the data layer should return what the database holds and the tool layer is already the thing whose job is shaping output for a model. Done inside the single &lt;code&gt;json()&lt;/code&gt; helper every tool return passes through, so a new tool can't forget. It took roughly a third off &lt;code&gt;get_nutrition&lt;/code&gt;'s payload for a seven-day window, and proportionally more over a ninety-day series.&lt;/p&gt;

&lt;h2&gt;
  
  
  The counterweight
&lt;/h2&gt;

&lt;p&gt;I want to be careful not to oversell the structural argument, because the model repeatedly did better than the structure asked of it.&lt;/p&gt;

&lt;p&gt;Nine deliberately trappy questions, run through &lt;a href="https://github.com/mattstratton/mattstratton-fitness-tracker/blob/main/scripts/probe-coach.ts" rel="noopener noreferrer"&gt;a committed probe script&lt;/a&gt; so the results are something you can reproduce rather than something I remember. Three of them came back with reasoning the prompt does not contain.&lt;/p&gt;

&lt;p&gt;Asked what my VO2max was doing, it reported the upward trend and then warned, unprompted, that Apple derives the figure from outdoor walk and run heart-rate data and that "the estimate is affected by weight dropping as well as by fitness," landing on "suggestive, not conclusive" instead of claiming a fitness gain. Nothing in the prompt mentions VO2max at all.&lt;/p&gt;

&lt;p&gt;Asked whether I was stalling on squat, it separated T1 from T2 work by inspecting the weights and set counts. The tier hint that exists elsewhere in this codebase is not exposed to any tool.&lt;/p&gt;

&lt;p&gt;And asked about sleep, it read the coverage figures before answering and declined to grade anything on 21 nights. That answer also contained a mistake that was entirely mine, which I didn't spot for another hour. It's the last section of this post.&lt;/p&gt;

&lt;p&gt;That last one is my favourite, because it was an accident. I added a &lt;code&gt;days365&lt;/code&gt; field to make the metric index honest about coverage, for the reasons above. The model appropriated it as a generic "is there enough data here to answer this" gate. A field added for one reason got used for a better one, which is worth knowing before you trim a tool payload on token grounds.&lt;/p&gt;

&lt;p&gt;Structure constrains the failure modes. It doesn't cap the upside.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signals I wanted and couldn't build
&lt;/h2&gt;

&lt;p&gt;The obvious next feature is cross-domain: does bad sleep predict missed lifts, does a deep deficit show up in recovery markers. Every signal in the app reads exactly one domain, and a few of them contain advisory copy gesturing at relationships the code never actually checks.&lt;/p&gt;

&lt;p&gt;Before writing any of it I checked whether the three most obvious hypotheses hold against real history. They don't get a real test, and why not is the finding.&lt;/p&gt;

&lt;p&gt;The only lift the stall rule flags is one it's supposed to ignore. Running &lt;code&gt;stalling()&lt;/code&gt; unfiltered across 2,545 sets and 190 sessions returns exactly one hit: triceps pushdown, parked at 47.5lb for two sessions. That's a T3 accessory, and a T3 parking at one weight is the program working as designed rather than a stall. It's precisely the false positive the tier filter exists to suppress, and with the filter on there is nothing left.&lt;/p&gt;

&lt;p&gt;The recovery rule has fired once in nine years. I ran the real &lt;code&gt;overreaching()&lt;/code&gt; function over every day of history rather than approximating its baseline maths in SQL, because getting a rule subtly wrong by hand is the exact thing this project keeps catching me doing. Out of 2,409 days: &lt;code&gt;ok&lt;/code&gt; 2,344 times, &lt;code&gt;unknown&lt;/code&gt; 48, &lt;code&gt;watch&lt;/code&gt; 16, and &lt;code&gt;act&lt;/code&gt; a single time, on 25 April 2022. Nothing has gone past &lt;code&gt;ok&lt;/code&gt; since June 2024.&lt;/p&gt;

&lt;p&gt;That last paragraph is a correction. My own notes said &lt;code&gt;act&lt;/code&gt; had never fired at all, and I believed it for months, because the script that produced that claim was a one-off I never committed and never re-ran. Writing it as &lt;a href="https://github.com/mattstratton/mattstratton-fitness-tracker/blob/main/scripts/verify-signal-history.ts" rel="noopener noreferrer"&gt;&lt;code&gt;npm run verify-signals&lt;/code&gt;&lt;/a&gt; took fifteen minutes and immediately contradicted the note. Make the check runnable and it will eventually tell you something you didn't want to hear.&lt;/p&gt;

&lt;p&gt;And consistent nutrition logging started on 13 July, so four complete weeks is what I'd be correlating against. That's an anecdote wearing a sample's clothes.&lt;/p&gt;

&lt;p&gt;So there are no cross-domain signals in this app, and the reason is that the events they'd correlate don't exist in my data yet. Filed as a dated follow-up rather than closed, because the hypotheses aren't rejected, they're just unanswerable with what's on hand.&lt;/p&gt;

&lt;p&gt;I find this more satisfying than shipping the feature would have been. The version of me that skipped the check builds four correlation signals, they all sit at &lt;code&gt;unknown&lt;/code&gt; forever, and I learn nothing except that the page is noisy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still unverified
&lt;/h2&gt;

&lt;p&gt;Two honest gaps, because a post about verification that ends on a clean note is a post that hasn't looked hard enough.&lt;/p&gt;

&lt;p&gt;The ask interface has never been rendered in a browser. The Chrome extension couldn't inject into localhost, so the streaming format is verified via curl and unit tests on the decoder and nothing else. It works when I use it on my phone against production, which is evidence but not a test.&lt;/p&gt;

&lt;p&gt;And the &lt;code&gt;unknown&lt;/code&gt; signal path is untested against live data, because nothing is currently &lt;code&gt;unknown&lt;/code&gt;. The instruction telling the model how to handle it is tested. The behaviour isn't.&lt;/p&gt;

&lt;p&gt;Some numbers, since I've spent this whole series insisting on them. Across the nine probes, measured today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  #  secs  ttft  turns    in    out  cacheR  cacheW     cost
   1   6.9   6.1      2   705     83   14138       0  $0.0127
   3  10.2   6.6      2  3989    299   14138       0  $0.0345
   8  13.6  10.1      3 12119    458   21207       0  $0.0826
   9  16.8   1.7      2 13344    660   14138       0  $0.0903
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cached prefix is &lt;strong&gt;7,069 tokens&lt;/strong&gt; of system prompt plus thirteen tool definitions, and &lt;code&gt;cacheWrite&lt;/code&gt; is zero on every turn after the first, which is the number to watch. If it ever stops being zero, something volatile has crept into the prefix and every turn is paying full freight. A two-turn question reads that prefix twice, hence 14,138.&lt;/p&gt;

&lt;p&gt;Nine questions cost 37 cents in total, from 1.3 cents for "how many calories today" to 9 cents for the ones that pull a year of series data. Latency ran 6.9 to 16.8 seconds end to end, with first text arriving anywhere from 1.7 to 10.1 seconds in. That spread is why streaming and a visible status line were not a nicety: ten seconds of blank screen reads as a hang, and the honest fix is to show the thing working rather than to make it faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  And one gap I didn't know I had
&lt;/h2&gt;

&lt;p&gt;Everything above is a gap I could name. Re-running those probes turned up one I couldn't, and it was sitting in the answer I'd just finished praising.&lt;/p&gt;

&lt;p&gt;The sleep response ended with "sleep coverage overall is ~7% of days, so this is informational only." Seven percent is wrong. Over the last thirty days it's seventy, because my watch-wearing changed in July.&lt;/p&gt;

&lt;p&gt;The model wasn't guessing and it wasn't reading that from the data. It was reading it from &lt;code&gt;lib/coach/context.ts&lt;/code&gt;, where I had written &lt;strong&gt;"Sleep has roughly 7% coverage"&lt;/strong&gt; into the system prompt as a standing fact, months ago, back when it was true.&lt;/p&gt;

&lt;p&gt;So the chat told me something false about my own body, sourced from the one component I'd exempted from the argument this entire post makes. The tools compute. The prompt asserts. Assertions rot, and this one had quietly reached the point where the app was contradicting its own database.&lt;/p&gt;

&lt;p&gt;Then I went to delete it and found out why it had survived.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prompt: carries the remaining data traps&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/single weigh-in is noise/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/7% coverage/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// &amp;lt;- this line&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stale number was under test. That assertion &lt;em&gt;required&lt;/em&gt; it to be present, so anyone who spotted the problem and corrected it would have gone red and, quite reasonably, put it back. A wrong fact had acquired a defender.&lt;/p&gt;

&lt;p&gt;Two tests below that one sits &lt;code&gt;prompt: forbids numbers that did not come from a tool&lt;/code&gt;. Two below that, &lt;code&gt;prompt: hardcodes no target values&lt;/code&gt;, with a comment explaining that targets change weekly so the model has to fetch them rather than be told. The principle was already written down. It was already enforced for one kind of number. And for another kind, a test was holding the violation in place.&lt;/p&gt;

&lt;p&gt;Chasing the string turned up seven copies, including a live row in the database and the coaching skill I run from my terminal. I had fixed one of them that same morning, in &lt;code&gt;CLAUDE.md&lt;/code&gt;, and considered the job done.&lt;/p&gt;

&lt;p&gt;The fix isn't to correct the figure. It's to delete it and let &lt;code&gt;list_metrics&lt;/code&gt; report coverage, which it already does, and which is how the model knew to be careful about sleep in the first place. The test now asserts the inverse: &lt;code&gt;assert.doesNotMatch(buildSystemPrompt(), /\d+(\.\d+)?%\s*coverage/i)&lt;/code&gt;, so the next person to write a percentage into that prompt gets stopped instead of protected.&lt;/p&gt;

&lt;p&gt;Which is the argument for tool shape over prompt text, arriving as a bug rather than a principle, in the one part of the system I'd decided didn't need it.&lt;/p&gt;




&lt;p&gt;The other two in this set: &lt;a href="https://dev.to/mattstratton/the-past-keeps-changing-235g"&gt;The past keeps changing&lt;/a&gt; is why the storage layer under all this is append-only, and &lt;a href="https://dev.to/mattstratton/every-number-i-didnt-measure-was-wrong-4j34"&gt;every number I didn't measure was wrong&lt;/a&gt; is the five figures I had to retract while writing these.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>programming</category>
      <category>database</category>
    </item>
    <item>
      <title>Every Number I Didn't Measure Was Wrong</title>
      <dc:creator>Matty Stratton</dc:creator>
      <pubDate>Mon, 17 Aug 2026 14:19:59 +0000</pubDate>
      <link>https://dev.to/mattstratton/every-number-i-didnt-measure-was-wrong-4j34</link>
      <guid>https://dev.to/mattstratton/every-number-i-didnt-measure-was-wrong-4j34</guid>
      <description>&lt;p&gt;I went to publish something about the database behind my personal health tracker, and fact-checking it took a day. By the end, most of the numbers I had written down to justify that database's design had turned out to be wrong.&lt;/p&gt;

&lt;p&gt;Not wildly wrong. Wrong in the quiet way, where the conclusion still holds and the reasoning under it has rotted. Every single one had the same cause: I measured something adjacent to the thing I cared about, wrote the result into a comment, and then the comment outlived my memory of where the number came from.&lt;/p&gt;

&lt;p&gt;The setup, briefly. Ten years of Apple Health data plus two and a half years of lifting, &lt;a href="https://dev.to/mattstratton/the-past-keeps-changing-235g"&gt;on SQLite until it lost data twice&lt;/a&gt; and on TimescaleDB since. 73,210 Reports across 81 metrics in a 30 MB database, and the &lt;a href="https://github.com/mattstratton/mattstratton-fitness-tracker" rel="noopener noreferrer"&gt;repo is public&lt;/a&gt;. That last part is why this was worth a day of anyone's time: a wrong number in a public schema comment is a wrong number somebody else might trust.&lt;/p&gt;

&lt;p&gt;Five of them, roughly in order of how badly the arithmetic was wrong. The last one is barely wrong at all and is by some distance the one that should worry you.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The segment key, wrong twice in opposite directions
&lt;/h2&gt;

&lt;p&gt;TimescaleDB's columnstore lets you nominate a &lt;code&gt;segmentby&lt;/code&gt; column. Rows sharing that value get grouped into a compression batch together, which is where most of the ratio comes from. For a tall, narrow table storing &lt;code&gt;(day, metric, value)&lt;/code&gt;, &lt;code&gt;metric&lt;/code&gt; is the textbook choice, and every guide will tell you so.&lt;/p&gt;

&lt;p&gt;I didn't use it. My schema comment explained why: roughly 69 rows per metric per yearly chunk, against a rule of thumb that wants more than 100 rows per segment value per chunk. Below the line, so no segmenting. That comment sat there looking authoritative for months.&lt;/p&gt;

&lt;p&gt;Re-running it against the actual table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;yr    rows_per_metric
2019  206
2022  197
2020  174
2021  173
2024  153
2023  152
2026  124
2025   93
2018   92
2016   74
2017   48
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Average about 135. Seven of eleven years sit &lt;em&gt;above&lt;/em&gt; the guideline, not below. My comment was wrong in the direction that reverses the decision.&lt;/p&gt;

&lt;p&gt;The cause is stupid and completely mechanical. I computed it from the 56,000 data points in the export files rather than from the rows that landed. The parser fans out: one &lt;code&gt;heart_rate&lt;/code&gt; point becomes three metrics, one &lt;code&gt;sleep_analysis&lt;/code&gt; point becomes six. 56,000 points went in, 73,000 rows came out, and I'd done arithmetic on the input file.&lt;/p&gt;

&lt;p&gt;So I corrected it, felt good for about four minutes, and then looked at the distribution.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;yr    metrics  &amp;gt;=100  &amp;lt;100  &amp;lt;20   median
2016  38        9     29    25     10
2017  40        4     36    27      9
2024  81       34     47     6     69
2025  76       23     53    46      3
2026  77       60     17     6    117
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The average is dragged up by a handful of metrics that fire every day. Step count has ten years of unbroken coverage. Vitamin D has whatever MacroFactor felt like logging that week. In most years &lt;em&gt;more&lt;/em&gt; metrics fall below the threshold than above it, and in 2025 the median metric has three rows.&lt;/p&gt;

&lt;p&gt;Right call, wrong reasoning, twice. And I should be straight about how far the correction actually goes: I have never built the segmented version and compared ratios. What I have is a good argument sitting where a measurement ought to be, which is the exact failure this post is about, showing up inside the section where I'm supposedly fixing it.&lt;/p&gt;

&lt;p&gt;The transferable bit is smaller than the story. Compute rows-per-segment-value from the loaded table rather than from whatever you fed it, and look at the spread instead of the mean. An average over a skewed distribution is the one statistic guaranteed to hide the shape of your problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. "About a week," which I appear to have made up
&lt;/h2&gt;

&lt;p&gt;Health Auto Export revises a day after that day has closed. That's the central fact of this whole system, and how &lt;em&gt;long&lt;/em&gt; it keeps revising decides the chunk interval: the revision window has to fit inside the chunk that's still uncompressed, or a routine correction has to decompress historical data to land.&lt;/p&gt;

&lt;p&gt;My schema comment said "about a week." So did the README. So did two other files.&lt;/p&gt;

&lt;p&gt;I never measured it. I picked the figure up somewhere early, it sounded about right, and it hardened into documentation through nothing but repetition across four files.&lt;/p&gt;

&lt;p&gt;What the live data says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;days_late  reports  distinct_days
0          1610     6
1          2298     6
2          110      4
3-7        62-69    1 each
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every value change happens at a one-day lag. Nothing at lag two or beyond has changed a value. And that 3-to-7-day tail is a single day, contaminated: it has one &lt;code&gt;hae_backfill&lt;/code&gt; row and one live row reported on the same date, which is the migration overlapping itself rather than HealthKit revising anything.&lt;/p&gt;

&lt;p&gt;The honest caveat, which matters more than the number: &lt;strong&gt;the live pipeline has been running for five days.&lt;/strong&gt; "No seven-day tail" is weak evidence, not proof. So the position is one day measured, longer assumed, and yearly chunks are absurdly conservative for either.&lt;/p&gt;

&lt;p&gt;The decision was never at risk. But "the assumption is safe" and "the assumption is verified" are different claims and I was only ever entitled to one of them. The comments now say which is which, which took ten minutes and should have happened months ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The watermark, which I diagnosed twice and understood once
&lt;/h2&gt;

&lt;p&gt;The number in this one is &lt;code&gt;INTERVAL '1 hour'&lt;/code&gt;, and unlike the others it isn't a measurement I got wrong. It's a measurement I never took, because I reasoned my way to a value instead and the reasoning was flawed. It's also the only entry here that reached a user, where user means me, on my phone, in a grocery store.&lt;/p&gt;

&lt;p&gt;MacroFactor said I'd eaten 1,556 calories. My own site said 688. Both were reading the same database.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.tigerdata.com/docs/use-timescale/latest/continuous-aggregates/" rel="noopener noreferrer"&gt;Real-time aggregation&lt;/a&gt; unions materialized buckets with raw rows &lt;em&gt;newer than the watermark&lt;/em&gt;. It's a very good feature and it's why a dashboard doesn't wait on a refresh job. But once a bucket has been materialized, raw rows for that bucket are never consulted again until something explicitly refreshes it.&lt;/p&gt;

&lt;p&gt;Read that sentence again, because I didn't. "Fresh past the watermark" is not the same promise as "always fresh," and I had cheerfully substituted the second. A Report landing later the same day never gets looked at again, so the view goes on serving a stale number with total confidence, which it's entitled to do because I'm the one who configured it that way.&lt;/p&gt;

&lt;p&gt;My first fix blamed the backfill, which called &lt;code&gt;refresh_continuous_aggregate(cagg, NULL, NULL)&lt;/code&gt; and materialized every bucket including today's. That was a real cause, so I narrowed the refresh, felt clever, and moved on.&lt;/p&gt;

&lt;p&gt;It came back, and there were two things I'd had wrong.&lt;/p&gt;

&lt;p&gt;The watermark only moves forward. Refreshing a narrower window afterwards doesn't walk it back, so today stayed materialized and the only way to clear it was to rebuild the aggregate.&lt;/p&gt;

&lt;p&gt;And my refresh policy was doing it too. I'd set &lt;code&gt;end_offset =&amp;gt; INTERVAL '1 hour'&lt;/code&gt;, reasoning that a bucket ending at midnight tonight can't possibly be older than an hour ago. That reasoning is simply wrong, and the watermark had duly advanced past today.&lt;/p&gt;

&lt;p&gt;The fix is belt and braces on purpose. &lt;code&gt;end_offset&lt;/code&gt; widened to two days so the current day is never a refresh candidate, &lt;em&gt;and&lt;/em&gt; the application reads today straight from the raw log with &lt;code&gt;DISTINCT ON ... ORDER BY reported_at DESC&lt;/code&gt;, which computes exactly what &lt;code&gt;last(value, reported_at)&lt;/code&gt; computes. Having misjudged the same rule twice, I stopped resting correctness on my ability to reason about it.&lt;/p&gt;

&lt;p&gt;For the good version of real-time aggregates rather than my footgun collection, Sven Klemm's &lt;a href="https://www.tigerdata.com/blog/achieving-the-best-of-both-worlds-ensuring-up-to-date-results-with-real-time-aggregation" rel="noopener noreferrer"&gt;original post&lt;/a&gt; is still the clearest explanation of the mechanism, and there's a &lt;a href="https://www.tigerdata.com/docs/build/tips-and-tricks/troubleshoot-continuous-aggregates" rel="noopener noreferrer"&gt;troubleshooting page&lt;/a&gt; covering this precise class of problem that would have saved me a week. The behavior isn't a bug. My mental model was.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A compression ratio measured against my own test loop
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before  after    ratio  chunks
13 MB   1496 kB  8.7    12 (11 compressed)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight point seven times, on a database whose entire before state was thirteen megabytes. At this size that's not a benefit and I won't dress it up as one. Compression earns its keep when you're paying to retain hundreds of gigabytes, which is a sentence about somebody else's database.&lt;/p&gt;

&lt;p&gt;It's worth showing anyway, because the mechanism is legible here in a way it isn't at scale. Tall, narrow, heavily repeated values in the ordering column. Nothing about that cares how many rows you have.&lt;/p&gt;

&lt;p&gt;Two things I got wrong about it.&lt;/p&gt;

&lt;p&gt;I measured 11.9x first, locally, and reported it to myself as good news. It was garbage. Re-running an idempotent backfill over and over leaves dead tuples, which inflate the uncompressed "before" without adding any data. I was benchmarking my own test loop and feeling great about it. Measure compression on a freshly loaded table, never on one you've been iterating against.&lt;/p&gt;

&lt;p&gt;And I wrote a note promising to re-measure once live restatements had accumulated, which turned out to be more interesting than the ratio. I can't. Every live Restatement lands in the current chunk, chunks are yearly, and the columnstore policy waits 30 days past a chunk's range. That chunk runs to March 2027, so nothing written since going live compresses until roughly April 2027. The follow-up is eight months out rather than pending, and I only worked that out because I went looking instead of promising.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. One number that was true and misleading at the same time
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; said sleep had "~7% coverage," and concluded that sleep was too sparse to build anything on, which is why the app displays sleep without grading it. Perfectly reasonable inference from the number it had.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;window     nights  possible  pct
last 365d  23      365        6.3
last 90d   21      90        23.3
last 30d   21      30        70.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The year figure was right. It was also concealing that &lt;strong&gt;21 of the 23 recorded nights in the entire year are in the last thirty days.&lt;/strong&gt; Current coverage is 70%. Something about my watch-wearing changed in July and the annual average will take a year to notice.&lt;/p&gt;

&lt;p&gt;Nothing here was ever wrong, exactly. One number was doing the work of three, and the guidance built on it ("basically no sleep data") stopped being true without anything visibly breaking. That's a worse failure mode than a number that's simply incorrect, because there's no moment where it flips.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern, since there is one
&lt;/h2&gt;

&lt;p&gt;Four of these five were caught in a single day, by the same move: run the query against the real artifact instead of trusting the note. None of the queries took more than a minute to write. The expensive part was never the measurement, it was noticing that a measurement was owed.&lt;/p&gt;

&lt;p&gt;What they have in common is that a plausible number outlives whatever made it plausible. It gets written down once, in a comment or a doc or a README, in a moment when you have the context to know what it means and what it excludes. Then the context evaporates and the number stays, in the imperative mood, looking like a fact.&lt;/p&gt;

&lt;p&gt;And they don't always sit there passively waiting to be caught. The sleep figure above turned out to have seven copies, one of them in the system prompt of the app's chat, where it was being read aloud to me as though it were current. When I went to delete that copy, a unit test went red: it asserted the prompt &lt;em&gt;must contain&lt;/em&gt; the string "7% coverage." The wrong number had acquired a defender, and anyone who noticed it before me would have hit the same red test and reasonably concluded they were the ones in error. That's the version of this failure I'd least like to meet again, because diligence doesn't save you from it. Diligence is what puts it back.&lt;/p&gt;

&lt;p&gt;Three habits came out of this, and I've actually adopted them rather than merely resolving to.&lt;/p&gt;

&lt;p&gt;The first is to write the method next to the number. Not "~69 rows per metric" but "~69 rows per metric, from the export scan." That second version carries its own bug report, visible to anyone who reads it including me six months later. The first version is where I lost months.&lt;/p&gt;

&lt;p&gt;The second is to say, in the artifact itself, which side of measured-versus-assumed a figure sits on. My chunk-interval comment now has MEASURED and ASSUMED in capitals, which looks a bit much and has already stopped me re-quoting a guess as a finding twice.&lt;/p&gt;

&lt;p&gt;The third is to distrust any single number describing a distribution. Both of my segment-key figures were averages and both were wrong, in opposite directions, because a skewed distribution has no meaningful average. The sleep figure failed identically, except across time rather than across metrics.&lt;/p&gt;

&lt;p&gt;It's the engineering equivalent of labelling your leftovers, and the only reason it's worth a post is that I'd have told you I already did all three, right up until the afternoon I checked.&lt;/p&gt;




&lt;p&gt;Companion pieces: &lt;a href="https://dev.to/mattstratton/the-past-keeps-changing-235g"&gt;The past keeps changing&lt;/a&gt; is the argument this project exists to make, about data that revises itself and why upsert makes that unfalsifiable. &lt;a href="https://dev.to/mattstratton/dont-give-the-model-sql-5h32"&gt;Don't give the model SQL&lt;/a&gt; is about the app on top of it.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>testing</category>
      <category>postgres</category>
      <category>devjournal</category>
    </item>
    <item>
      <title>The Past Keeps Changing</title>
      <dc:creator>Matty Stratton</dc:creator>
      <pubDate>Mon, 17 Aug 2026 14:19:58 +0000</pubDate>
      <link>https://dev.to/mattstratton/the-past-keeps-changing-235g</link>
      <guid>https://dev.to/mattstratton/the-past-keeps-changing-235g</guid>
      <description>&lt;p&gt;I moved my personal health tracker onto a time-series database this month. It holds 69,000 rows in thirty megabytes.&lt;/p&gt;

&lt;p&gt;That is not a defensible reason to use one, and I want to be blunt about it before you get there on your own. Nothing I did made anything faster and nothing needed to.&lt;/p&gt;

&lt;p&gt;What it replaced was smaller still. An 840 KB SQLite file on my laptop, seventeen metrics, about 280 health observations going back three months, plus two and a half years of lifting history. For what it was doing, that was the right call, and I'd make it again.&lt;/p&gt;

&lt;p&gt;I moved because I'd been quietly losing data for months and the schema was built in a way that made it impossible for me to notice.&lt;/p&gt;

&lt;p&gt;The whole repo is public at &lt;a href="https://github.com/mattstratton/mattstratton-fitness-tracker" rel="noopener noreferrer"&gt;mattstratton/mattstratton-fitness-tracker&lt;/a&gt;, schema and decision records included, minus my actual data for reasons I hope are obvious. Every query below runs against the live database, and every number in this post is one query away from you checking it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it used to be
&lt;/h2&gt;

&lt;p&gt;There was no server and no app. &lt;a href="https://www.healthyapps.dev/" rel="noopener noreferrer"&gt;Health Auto Export&lt;/a&gt; wrote JSON files into two iCloud folders. A launchd agent on my MacBook polled those folders every hour, parsed whatever it found, and upserted the results into an 840 KB SQLite file next to it. When I wanted coaching I opened Claude Code on that same laptop and ran a skill that queried it.&lt;/p&gt;

&lt;p&gt;That was the entire system. The Mac was the infrastructure, which sounds fine right up until the Mac is closed.&lt;/p&gt;

&lt;p&gt;About 150 lines of that code existed for no reason other than surviving iCloud, and every one of them was written in response to something that had already broken. Files under &lt;code&gt;~/Library/Mobile Documents&lt;/code&gt; can exist as metadata-only placeholders that a launchd agent cannot materialise. That one failed with &lt;code&gt;EDEADLK&lt;/code&gt;, stranded four days of workouts, and looked exactly like file corruption. Two automations wrote the same data into two different folders, so the winner was whichever sorted last alphabetically, until I changed it to whichever had the later mtime. On files whose mtimes iCloud does not reliably update.&lt;/p&gt;

&lt;p&gt;Hold onto that last one. It comes back, and it turns out to be the whole problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Losing data twice without noticing
&lt;/h2&gt;

&lt;p&gt;Each hourly run did the obvious thing:&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="p"&gt;...&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New value for a day? Overwrite the old one. That is what upsert is for, and it cost me data twice.&lt;/p&gt;

&lt;p&gt;The first one was ugly. An iOS update silently dropped Health Auto Export's HealthKit read permission for Weight &amp;amp; Body Mass and Lean Body Mass, while leaving Body Fat % and BMI alone. The exports kept running. They kept succeeding. They contained empty arrays for exactly two metrics, and the sync log wrote &lt;code&gt;status='ok'&lt;/code&gt; every hour for five days straight.&lt;/p&gt;

&lt;p&gt;Five days of weigh-ins gone, with the monitoring reporting green the entire time.&lt;/p&gt;

&lt;p&gt;The second was quieter and I caught it by accident. A midday export made a 1,241 kcal day read as 333, because the export landed at lunchtime and the upsert dutifully replaced a complete day with a partial one.&lt;/p&gt;

&lt;p&gt;Two bugs, and if you squint they're the same bug. Upsert answers "did this value change?" by destroying the evidence you would need to answer it. Nothing in that schema could distinguish "never reported" from "reported as empty" from "reported correctly, then clobbered by something worse."&lt;/p&gt;

&lt;p&gt;The rule I got out of it: never ask whether the sync ran, ask whether the data is current. Those sound identical right up until a sync succeeds at doing nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question that dissolved it
&lt;/h2&gt;

&lt;p&gt;I spent an embarrassing amount of time on "should this table upsert or append?" and got nowhere, because it's the wrong question and wrong questions don't have answers.&lt;/p&gt;

&lt;p&gt;The right one turned out to be: &lt;strong&gt;is the thing I'm storing an observation about a day, or a report that arrived at a time?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Those are different objects. What I burned on August 9th is a fact about August 9th. "My watch told me, on August 10th, that I'd burned 1,834 kcal on August 9th" is a fact about a conversation. The first is stable. The second happens over and over, from multiple sources, with different answers.&lt;/p&gt;

&lt;p&gt;The old schema mashed both into one row, which is exactly why both data-loss bugs were invisible. You cannot detect that a report was wrong if you've thrown away the fact that a different report ever existed.&lt;/p&gt;

&lt;p&gt;Separate them and the write model stops being a judgement call. Reports append, always. There is no &lt;code&gt;UPDATE&lt;/code&gt; statement anywhere in this codebase that touches health data. Current truth is something you derive instead of something you maintain.&lt;/p&gt;

&lt;p&gt;It's the difference between a whiteboard and a logbook.&lt;/p&gt;

&lt;p&gt;Four words I'll use for the rest of this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Observation&lt;/strong&gt;: what a metric was on a given day&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report&lt;/strong&gt;: a source telling you, at a specific time, what it thinks an Observation was&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observed Day&lt;/strong&gt;: the calendar day being described, in one fixed timezone&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restatement&lt;/strong&gt;: a later Report that disagrees with an earlier one about the same Observed Day&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those first two are why I have two row counts and both are correct. 73,210 Reports. 69,613 distinct Observations. The gap is every time something changed its mind, and under the old schema that gap was zero by construction.&lt;/p&gt;

&lt;p&gt;Look at where the weight sits in that second definition. &lt;em&gt;At a specific time&lt;/em&gt;. That timestamp is what orders Restatements, and ordering Restatements correctly is the entire job. Under the old pipeline it was the file's modification time. A file iCloud had placed there. With an mtime iCloud does not reliably update.&lt;/p&gt;

&lt;p&gt;The field the whole model turns on was being inferred from filesystem metadata that lies, and I had already been bitten by that metadata badly enough to rewrite the tie-breaking logic once. It never occurred to me that the same unreliable field was also deciding which version of Tuesday I believed. It's now the HTTP receipt time on the POST, which isn't an approximation of when a source told me something. It's when a source told me something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Here is what changing its mind looks like
&lt;/h2&gt;

&lt;p&gt;Basal energy for August 9th, every Report in the order it arrived:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;value     reported_at (America/Chicago)
718.42    2026-08-09      &amp;lt;- the day, in progress
1746.06   2026-08-09
1817.51   2026-08-09
1842.11   2026-08-10      &amp;lt;- the day is over now
1834.03   2026-08-10         and the number goes DOWN
2319.02   2026-08-10
2319.02   2026-08-11      &amp;lt;- settles, two days later
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transition from 1842.11 to 1834.03 is the one that matters. A day still in progress can only accumulate, so a rising number proves nothing. A number going &lt;em&gt;down&lt;/em&gt; on a day that already ended is Apple revising history after the fact.&lt;/p&gt;

&lt;p&gt;Almost every time-series database ever built assumes that doesn't happen. Write once, read forever, the past is immutable, timestamps move in one direction only. It's such a load-bearing assumption that plenty of these systems list it as a feature.&lt;/p&gt;

&lt;p&gt;My health data has not read the marketing copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where TimescaleDB stops being incidental
&lt;/h2&gt;

&lt;p&gt;What replaced the laptop, before we get to the schema: Health Auto Export now posts straight to a &lt;a href="https://vercel.com" rel="noopener noreferrer"&gt;Vercel&lt;/a&gt; function on a daily schedule, a scheduled job pulls lifting history from &lt;a href="https://www.liftosaur.com/" rel="noopener noreferrer"&gt;Liftosaur&lt;/a&gt;, and both land in &lt;a href="https://www.tigerdata.com/timescaledb" rel="noopener noreferrer"&gt;TimescaleDB&lt;/a&gt; on &lt;a href="https://www.tigerdata.com/cloud" rel="noopener noreferrer"&gt;Tiger Cloud&lt;/a&gt;. No files, no iCloud, no Mac. A &lt;a href="https://nextjs.org" rel="noopener noreferrer"&gt;Next.js&lt;/a&gt; app reads it and tells me whether I hit protein, which is a separate post.&lt;/p&gt;

&lt;p&gt;That change alone deleted the 150 lines and, more to the point, made Report Time a real measurement instead of an inference.&lt;/p&gt;

&lt;p&gt;It also turned up something I hadn't gone looking for. Once nothing was reading files hourly, there was no reason not to ask HealthKit for everything it had, so I did, and it handed back 87 MB of JSON going back to January 2016. Ten years of resting heart rates and step counts and body weights had been sitting on my phone the entire time. The old pipeline could see three months of it, because three months was when I'd set it up, and it had never occurred to me to ask for more. That backfill loaded in 2.2 seconds and quadrupled the useful history of the whole project before I'd written a single interesting query.&lt;/p&gt;

&lt;p&gt;Everything past this point is a modeling argument you could implement on any relational database, and for most of this post's length that is exactly what I would tell you to do.&lt;/p&gt;

&lt;p&gt;Here it stops being true.&lt;/p&gt;

&lt;p&gt;The Reports table is a hypertable partitioned on &lt;code&gt;observed_on&lt;/code&gt;, with no primary key and no unique constraint. That's not sloppiness, it's the point: uniqueness would make Restatement an error, and Restatement is the normal path.&lt;/p&gt;

&lt;p&gt;Current truth is a continuous aggregate.&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="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;observations_daily&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timescaledb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;continuous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;time_bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;observed_on&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;observed_on&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;last&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reported_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;last&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;reported_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reported_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;last_reported_at&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;AS&lt;/span&gt; &lt;span class="n"&gt;report_count&lt;/span&gt;      &lt;span class="c1"&gt;-- &amp;gt;1 means it was Restated&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;observations&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;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sit with &lt;code&gt;last(value, reported_at)&lt;/code&gt; for a second. That is last-write-wins. It is &lt;em&gt;precisely&lt;/em&gt; the semantics &lt;code&gt;ON CONFLICT DO UPDATE&lt;/code&gt; was giving me, expressed as an aggregate instead of as a destructive write, and every losing write is still there to query.&lt;/p&gt;

&lt;p&gt;That one line is most of what the migration bought me, and everything else in this post is housekeeping around it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;report_count&lt;/code&gt; is the part I didn't expect to lean on. It's just &lt;code&gt;count(*)&lt;/code&gt;, but it means every row in the view carries whether it has ever been argued about.&lt;/p&gt;

&lt;h2&gt;
  
  
  How often does this actually happen
&lt;/h2&gt;

&lt;p&gt;I asked three times and got three answers, all correct, spanning two orders of magnitude.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the question I asked                                          answer
of all 69,613 Observations, how many have &amp;gt;1 Report?            0.6%
of Observations on days the live pipeline has touched,
   how many have &amp;gt;1 Report?                                    53.6%
of Observations the pipeline first saw live, how many had
   their VALUE change after that day had already closed?       69.3%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero point six percent is junk, diluted by ten years of backfill that loaded once and has never been revised because nothing has looked at it since.&lt;/p&gt;

&lt;p&gt;Fifty-three point six is better and still wrong for my purposes, because "more than one Report" counts today's step count ticking upward all afternoon. That's a day accumulating, not history changing.&lt;/p&gt;

&lt;p&gt;The third one is the claim. &lt;strong&gt;Of the Observations this pipeline has watched land in real time, 69.3% had their value change after the day they describe was already over.&lt;/strong&gt; That's the majority path, not an edge case I went hunting for.&lt;/p&gt;

&lt;p&gt;It needs two caveats and it's going to need them forever. It's 140 of 202 Observations against a pipeline that has been live for five days, so read it as directional rather than as a rate. And it can only climb, because an Observation that hasn't been revised yet can still get revised tomorrow while one that has can't un-revise itself. Any figure here needs its as-of date welded on.&lt;/p&gt;

&lt;p&gt;I couldn't have measured any of this before, because the old schema's entire design was "make this unmeasurable."&lt;/p&gt;

&lt;h2&gt;
  
  
  What that buys you on a bad day
&lt;/h2&gt;

&lt;p&gt;Knowing how often your data revises itself is less interesting than knowing what you can do the day it goes wrong.&lt;/p&gt;

&lt;p&gt;One evening in August my food-logging app and this site disagreed about the same day by 868 calories, both of them reading the same database. I've written up &lt;a href="https://dev.to/mattstratton/every-number-i-didnt-measure-was-wrong-4j34"&gt;what caused that separately&lt;/a&gt;, because it's a TimescaleDB footgun with its own set of lessons and I diagnosed it wrong the first time. What belongs here is what the diagnosis cost.&lt;/p&gt;

&lt;p&gt;One query. Both Reports were sitting in the log with their timestamps on them, so I could see the number the page was serving, the number that had arrived after the page stopped looking, and the five hours between the two that explained the whole thing.&lt;/p&gt;

&lt;p&gt;Under the old schema the later write would have overwritten the earlier one and left nothing behind. I'd have had a wrong number on a page, no way to know it was wrong, and no way to work out when it had become wrong.&lt;/p&gt;

&lt;p&gt;Append-only didn't prevent that bug. It made the bug &lt;em&gt;legible&lt;/em&gt;, which at 9pm in a grocery store turns out to be worth considerably more.&lt;/p&gt;

&lt;h2&gt;
  
  
  A thing that was true for two and a half years and invisible
&lt;/h2&gt;

&lt;p&gt;The first Liftosaur sync against the new schema failed on this:&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;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;lifting_sets_weight_sane&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weight_lbs&lt;/span&gt; &lt;span class="o"&gt;&amp;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;Fifty-two sets across plank, crunch, hanging leg raise, inverted row and bodyweight squat come out of Liftosaur as &lt;code&gt;0lb&lt;/code&gt;. And &lt;code&gt;0lb&lt;/code&gt; means "no external load," which is already what &lt;code&gt;NULL&lt;/code&gt; meant in this schema for a set logged as &lt;code&gt;3x12&lt;/code&gt; with no weight segment at all.&lt;/p&gt;

&lt;p&gt;One concept, two representations, sitting in my training history since February 2024.&lt;/p&gt;

&lt;p&gt;Relaxing the constraint to &lt;code&gt;&amp;gt;= 0&lt;/code&gt; is the easy fix and the wrong one. Every "did I add weight this week?" query would then need &lt;code&gt;&amp;gt; 0&lt;/code&gt; instead of &lt;code&gt;IS NOT NULL&lt;/code&gt;, and &lt;code&gt;AVG(weight_lbs)&lt;/code&gt; would quietly average real loads against a pile of zeroes. Normalized in the parser instead.&lt;/p&gt;

&lt;p&gt;I want to be clear about the credit: that's a &lt;em&gt;Postgres&lt;/em&gt; win, not a TimescaleDB one. A CHECK constraint is about as old-fashioned as database features get. SQLite is typeless and had no constraints, so those rows sat there for two and a half years as an ambiguity nothing in the system was capable of noticing.&lt;/p&gt;

&lt;p&gt;The time-series parts of this story are the hypertable and the continuous aggregate. Everything else I got came from moving onto a relational database that has opinions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually ask you to do
&lt;/h2&gt;

&lt;p&gt;It isn't "move your side project to a time-series database." My dataset is a rounding error, the performance argument at my size doesn't exist, and you shouldn't let anyone including me cite my 73,000 rows as evidence that you need anything at all.&lt;/p&gt;

&lt;p&gt;My suggestion is smaller than that, and more annoying. Go find out whether your data revises itself.&lt;/p&gt;

&lt;p&gt;Most people have never checked, and the reason is that upsert makes it unfalsifiable. If your write path is &lt;code&gt;ON CONFLICT DO UPDATE&lt;/code&gt;, a source that quietly restates history looks exactly like a source that doesn't. There is no query you can run. The evidence was destroyed at write time, by you, deliberately, in a line of code that looked completely reasonable.&lt;/p&gt;

&lt;p&gt;Count how many of these are true where you work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Something upstream can restate a value for a day that has already closed&lt;/li&gt;
&lt;li&gt;Your only record that a value ever changed is an &lt;code&gt;updated_at&lt;/code&gt; column&lt;/li&gt;
&lt;li&gt;You cannot answer "what did we think this number was last Tuesday?"&lt;/li&gt;
&lt;li&gt;A sync that succeeds and delivers nothing looks identical to one that succeeds and delivers everything&lt;/li&gt;
&lt;li&gt;Your dashboard reads from a materialized view and nobody has checked what its watermark is doing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One is fine. Two is worth an afternoon. If you got four, you don't have a data pipeline, you have a very confident rumor.&lt;/p&gt;

&lt;p&gt;The fix isn't a migration. It's one question, asked before you design the table: &lt;strong&gt;is this an observation about a day, or a report that arrived at a time?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Get it right and most of the rest falls out.&lt;/p&gt;




&lt;p&gt;Two follow-ups to this one. &lt;a href="https://dev.to/mattstratton/every-number-i-didnt-measure-was-wrong-4j34"&gt;Every number I didn't measure was wrong&lt;/a&gt; is the confessional: five figures in this project that I never checked against the real thing, and the cheap arithmetic that would have caught each one. &lt;a href="https://dev.to/mattstratton/dont-give-the-model-sql-5h32"&gt;Don't give the model SQL&lt;/a&gt; is about the app on top, and why its chat interface has thirteen typed tools instead of a query box.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Contain Multitudes (and Also Three Git Repos)</title>
      <dc:creator>Matty Stratton</dc:creator>
      <pubDate>Mon, 06 Jul 2026 01:17:28 +0000</pubDate>
      <link>https://dev.to/mattstratton/i-contain-multitudes-and-also-three-git-repos-33pf</link>
      <guid>https://dev.to/mattstratton/i-contain-multitudes-and-also-three-git-repos-33pf</guid>
      <description>&lt;p&gt;My blog is older than most of the tools I use to run it. The oldest posts started life on LiveJournal sometime in the early 2000s, got dragged through a WordPress import at some point in the mid-2000s, spent the better part of a decade as a Hugo site, and, about a week ago, moved into an Astro 5 site that reproduces every one of those old URLs byte for byte. That's 2,630 posts spanning 2001 to 2020, frozen exactly where they landed.&lt;/p&gt;

&lt;p&gt;At the same time, &lt;a href="https://speaking.mattstratton.com" rel="noopener noreferrer"&gt;my speaking site&lt;/a&gt; (106 talks going back to 2012) moved off Notist and onto something I actually own. And the tool that syncs my dev.to drafts with git finally works the way it was supposed to the whole time, instead of the way it actually did for a while.&lt;/p&gt;

&lt;p&gt;All three of those used to be separate repos. As of earlier today, they're one monorepo, with the real git history from all three preserved, not squashed into "initial commit" and called a day. Here's the tour: how the two sites work, how dev.to fits into either of them, and a couple of things that broke in interesting ways along the trip.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it today
&lt;/h2&gt;

&lt;p&gt;Three systems, one person, now one &lt;a href="https://github.com/mattstratton/mattstratton-web" rel="noopener noreferrer"&gt;repo&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;mattstratton.com&lt;/strong&gt; is the personal site. It carries the 2,630-post legacy archive and a newer, general-purpose &lt;code&gt;/writing/&lt;/code&gt; section for anything I want to write that isn't archival.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;speaking.mattstratton.com&lt;/strong&gt; is the talk archive: every deck, every video, every event, self-hosted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mattstratton-dev-to&lt;/strong&gt; isn't a website at all. It's a small tool that keeps dev.to drafts and this git repo in sync, and it's the thing that lets a post published on dev.to also show up natively on &lt;a href="https://mattstratton.com" rel="noopener noreferrer"&gt;mattstratton.com&lt;/a&gt;, which is what's about to happen to this exact post.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They're merged into one monorepo now, but they're still three genuinely separate concerns underneath, with their own configs and their own reasons for existing. Let's take them one at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  mattstratton.com: preserving twenty years of receipts
&lt;/h2&gt;

&lt;p&gt;The current site is Astro 5 and Tailwind v4. The single rule that shaped every other decision in the migration: &lt;strong&gt;URL preservation is the prime directive.&lt;/strong&gt; &lt;a href="https://github.com/mattstratton/mattstratton-web/blob/master/astro.config.mjs" rel="noopener noreferrer"&gt;&lt;code&gt;astro.config.mjs&lt;/code&gt;&lt;/a&gt; sets &lt;code&gt;trailingSlash: 'always'&lt;/code&gt; and builds in directory format specifically to reproduce the old Hugo URLs exactly, which in turn had reproduced the WordPress URLs before that. Old inbound links, whether from a 2009 blog roll or a conference site linking a talk recap from 2014, still resolve. Nobody has to fix a dead link because I decided to rewrite my site in a different framework.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;posts&lt;/code&gt; collection is the frozen 2001 to 2020 archive, bulk-converted from Hugo. It doesn't grow. It just needs to keep existing. Everything new goes into &lt;code&gt;writing&lt;/code&gt;, and that one's got more going on than I expected when I started writing this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field guide: my own posts plus the good stuff I wrote elsewhere
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/writing/&lt;/code&gt; used to be scoped much more narrowly. The working plan before this project had it locked to Postgres-focused, authority-building content, an idea inherited from a content strategy document written for a completely different audience than the one reading my personal blog. The pushback that killed that scoping was simple: I contain multitudes, and a personal site that can only hold one topic isn't really personal anymore.&lt;/p&gt;

&lt;p&gt;What's actually interesting about &lt;code&gt;/writing/&lt;/code&gt; now, though, is that it isn't only my own posts. It's a merge of two genuinely different things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Native entries in &lt;code&gt;src/content/writing/&lt;/code&gt;, defined by the schema in &lt;a href="https://github.com/mattstratton/mattstratton-web/blob/master/src/content.config.ts" rel="noopener noreferrer"&gt;&lt;code&gt;content.config.ts&lt;/code&gt;&lt;/a&gt;: &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;pubDate&lt;/code&gt;, &lt;code&gt;topics&lt;/code&gt;, &lt;code&gt;draft&lt;/code&gt;, an optional &lt;code&gt;part&lt;/code&gt; for a curated four-part Postgres arc, and an optional &lt;code&gt;canonicalUrl&lt;/code&gt; for when the real original lives somewhere else entirely.&lt;/li&gt;
&lt;li&gt;A hand-maintained list of external links to posts I've written for my employer, Tiger Data, defined in &lt;a href="https://github.com/mattstratton/mattstratton-web/blob/master/src/data/field-guide.ts" rel="noopener noreferrer"&gt;&lt;code&gt;field-guide.ts&lt;/code&gt;&lt;/a&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;FieldGuideLink&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;part&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mechanics&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;limits&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;traps&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;decision&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are 14 of those right now, all pointing at tigerdata.com, each slotted into one of the same four &lt;code&gt;part&lt;/code&gt; buckets native posts can opt into. &lt;a href="https://github.com/mattstratton/mattstratton-web/blob/master/src/lib/writing.ts" rel="noopener noreferrer"&gt;&lt;code&gt;writing.ts&lt;/code&gt;&lt;/a&gt; normalizes both shapes into one common &lt;code&gt;GuideItem&lt;/code&gt; (&lt;code&gt;title&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;href&lt;/code&gt;, &lt;code&gt;external&lt;/code&gt;) so the template can render them identically, native entries first within each part, external links after, no interleaving by date. The one visual tell is a small "tigerdata.com ↗" badge next to external titles. No new tab, just an honest label about where you're about to land.&lt;/p&gt;

&lt;h2&gt;
  
  
  The newsletter: Buttondown does the sending, Astro just serves the pages
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/newsletter/&lt;/code&gt; works the same way as most of this site: fetch at build time, serve static files forever. &lt;a href="https://github.com/mattstratton/mattstratton-web/blob/master/src/lib/buttondown.ts" rel="noopener noreferrer"&gt;&lt;code&gt;buttondown.ts&lt;/code&gt;&lt;/a&gt; is an Astro content-layer loader that calls one endpoint, &lt;code&gt;GET /emails?ordering=-publish_date&lt;/code&gt; against Buttondown's API, follows pagination until it runs out of pages, and hands the result to Astro as the &lt;code&gt;newsletter&lt;/code&gt; collection. Every issue page, the archive index, and the RSS feed are then just static output from a normal &lt;code&gt;getCollection()&lt;/code&gt; call, same as any local markdown file.&lt;/p&gt;

&lt;p&gt;Two details worth calling out for anyone tempted to do the same thing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If &lt;code&gt;BUTTONDOWN_API_KEY&lt;/code&gt; isn't set, the loader logs a warning and returns an empty collection instead of failing the build. That's not an accident: it means anyone can clone this repo and build it locally without needing my API key.&lt;/li&gt;
&lt;li&gt;Buttondown stores each issue's body as Markdown, so the loader runs it through &lt;code&gt;marked.parse()&lt;/code&gt; before it ever reaches a template. The site never touches raw HTML from the API.
&lt;/li&gt;
&lt;/ul&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;key&lt;/span&gt; &lt;span class="o"&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;meta&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;BUTTONDOWN_API_KEY&lt;/span&gt; &lt;span class="o"&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;BUTTONDOWN_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BUTTONDOWN_API_KEY not set: newsletter archive will build empty.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  speaking.mattstratton.com: getting my slides back from a CDN I don't own
&lt;/h2&gt;

&lt;p&gt;The old speaking site lived on Notist, and Notist had two problems that eventually became one problem: video embeds had already started quietly breaking, and every slide deck was served off Notist's CDN. The day that account lapses, the images and PDFs go with it. A core part of a speaker's professional identity, every talk I've ever given, was living in someone else's database with a thin export option.&lt;/p&gt;

&lt;p&gt;So the new site's whole design goal is asset ownership and durability. Plain content in my own repo, on my own domain, that keeps working with no third-party dependency that can be shut off or go quietly stale. Not a redesign. A portability project.&lt;/p&gt;

&lt;p&gt;The interesting piece is the slide pipeline, because it runs entirely &lt;strong&gt;locally, before a commit ever happens&lt;/strong&gt;, and never on the deploy host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;originals/{id}.pdf --optimize(gs, 300dpi)--&amp;gt; public/slides/{id}.pdf --rasterize--&amp;gt; public/slides/{id}/{n}.webp
   (gitignored, full-res)          (committed, served, downloadable)     (committed, served, viewer images)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason it has to run locally: static hosts don't give you root, and you need root (or at least a real package manager) to run &lt;code&gt;pdftoppm&lt;/code&gt; and &lt;code&gt;gs&lt;/code&gt;. Trying to shell out to Ghostscript from inside a Netlify build is a fight you will lose. Doing the rasterization on my own machine and committing the output keeps the actual deploy build a boring, fast, host-agnostic &lt;code&gt;astro build&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Video embeds got the same "own it, don't proxy it" treatment. Instead of reproducing Notist's approach (a proxy that, again, had already started breaking), each talk stores a plain provider and video ID in frontmatter, and the page renders a lightweight embed facade that only loads the real player on click.&lt;/p&gt;

&lt;p&gt;The current numbers: 106 talks from 2012 to 2026, 93 events, 36 videos, 3,684 slide images. All of it committed, none of it one CDN outage away from disappearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the talks searchable
&lt;/h2&gt;

&lt;p&gt;Video is the one asset class the speaking site doesn't fully own. It can host the reference, but not the file itself. What it can own is the transcript, and that turned into a genuinely useful feature: full-text search across every talk I've ever given.&lt;/p&gt;

&lt;p&gt;Raw transcripts come from YouTube's auto-generated captions, pulled by &lt;a href="https://github.com/mattstratton/mattstratton-web/blob/master/mattstratton-speaking/scripts/transcripts.ts" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/transcripts.ts&lt;/code&gt;&lt;/a&gt; via &lt;code&gt;yt-dlp&lt;/code&gt;, cleaned up, and written to &lt;code&gt;public/transcripts/{id}.txt&lt;/code&gt;. Auto-generated captions are rough: no punctuation, mangled proper nouns, every "um" and "uh" transcribed faithfully. A &lt;a href="https://github.com/mattstratton/mattstratton-web/blob/master/mattstratton-speaking/.claude/skills/transcript-cleanup/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;transcript-cleanup&lt;/code&gt;&lt;/a&gt; Claude Code skill handles the cleanup pass, with a strict ruleset: fix punctuation, capitalization, and proper nouns, remove disfluencies, but never reword, paraphrase, or reorder anything actually said. When in doubt, the instruction is to leave the text alone rather than guess.&lt;/p&gt;

&lt;p&gt;Search itself runs client-side against two separate JSON payloads, built at deploy time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/mattstratton/mattstratton-web/blob/master/mattstratton-speaking/src/pages/search-index.json.ts" rel="noopener noreferrer"&gt;&lt;code&gt;search-index.json.ts&lt;/code&gt;&lt;/a&gt; is small: title, abstract, event, tags, year, per talk. Loaded on first open of the search box.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/mattstratton/mattstratton-web/blob/master/mattstratton-speaking/src/pages/deep-index.json.ts" rel="noopener noreferrer"&gt;&lt;code&gt;deep-index.json.ts&lt;/code&gt;&lt;/a&gt; is the heavy one, concatenating each talk's transcript and slide text into a single searchable blob, fetched lazily only once you actually type a query:
&lt;/li&gt;
&lt;/ul&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;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;readTranscript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;talk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;readSlideText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;talk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;talkUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;talk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/mattstratton/mattstratton-web/blob/master/mattstratton-speaking/src/components/Search.astro" rel="noopener noreferrer"&gt;&lt;code&gt;Search.astro&lt;/code&gt;&lt;/a&gt; builds a Fuse.js fuzzy index over each payload and merges the results, so searching for a phrase I actually said mid-talk, not just a word in the title, finds the right talk. No server, no database, just two static JSON files and a fuzzy-matching library doing all the work in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three repos become one
&lt;/h2&gt;

&lt;p&gt;The three-repos-in-one-monorepo move wasn't really about tidiness. Managing three repos independently was mild friction, sure, but the actual trigger was realizing that between a frozen archive and a newly-scoped &lt;code&gt;/writing/&lt;/code&gt; section, there was no home left on my own site for general writing. Once that was the real problem, "merge some repos" turned into "fix the architecture, and while we're in there, get dev.to crossposting actually working."&lt;/p&gt;

&lt;p&gt;The mechanism for the merge itself was &lt;code&gt;git subtree add&lt;/code&gt;, not a squash and not a filter-repo rewrite. That preserves full commit history from both source repos as real, verifiable ancestry, not just "the files are here now, trust me." Both source repos were young (about 50 and 22 commits respectively), so this wasn't decades of archaeology to protect. It was doing it right on principle, which is its own kind of satisfying.&lt;/p&gt;

&lt;p&gt;A few things bit us on the way that are worth flagging if you're ever tempted to merge repos that each bring their own CI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions workflows are only discovered at the true repo root's &lt;code&gt;.github/workflows/&lt;/code&gt;, never in a subdirectory.&lt;/strong&gt; The dev.to workflows got left nested under the subproject's own &lt;code&gt;.github/workflows/&lt;/code&gt; after the initial merge, which made them completely invisible to GitHub. No error. No warning. &lt;code&gt;gh workflow list&lt;/code&gt; just didn't show them, and they silently never ran again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Netlify doesn't scope rebuilds by subdirectory on its own.&lt;/strong&gt; Every push rebuilds every connected site unless you add an explicit &lt;code&gt;ignore&lt;/code&gt; check in each site's own &lt;code&gt;netlify.toml&lt;/code&gt;. And when Netlify skips a build for having no relevant changes, it shows that as "Canceled build due to no content change," which reads exactly like a failure in your inbox even though nothing is actually wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Base directory" and "Package directory" are different Netlify settings&lt;/strong&gt;, and the re-link flow for a repo with no npm workspace only shows you Base directory. Package directory just never appears. Worth reading the actual docs instead of assuming, which is a sentence I apparently still need to relearn every few years.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;astro check&lt;/code&gt; doesn't respect subproject boundaries.&lt;/strong&gt; Run from the monorepo root, it scanned an unrelated subproject's content collections using the other subproject's generated types and produced over 200 false type errors. One explicit &lt;code&gt;exclude&lt;/code&gt; in &lt;code&gt;tsconfig.json&lt;/code&gt; took that down to 3, all pre-existing and unrelated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If two or more of those apply to whatever you're about to merge, budget yourself an extra afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  dev.to: ownership, not direction
&lt;/h2&gt;

&lt;p&gt;The dev.to sync tool is built around one idea I'd genuinely recommend to anyone syncing content between a platform and git: &lt;strong&gt;ownership, not direction.&lt;/strong&gt; The full mental model lives in &lt;a href="https://github.com/mattstratton/mattstratton-web/blob/master/mattstratton-dev-to/CLAUDE.md" rel="noopener noreferrer"&gt;the repo's &lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/a&gt;, but the short version:&lt;/p&gt;

&lt;p&gt;It's tempting to think of this as bidirectional sync, keep dev.to and git in lockstep, push changes both ways. Don't do that. It produces an endless phantom-diff loop where each side keeps thinking it's the one with the real version. Instead, a post is dev.to-owned while it's being drafted, and git ignores it entirely. Once a human merges an import PR, it's git-owned forever, marked by the presence of an &lt;code&gt;id&lt;/code&gt; field in its frontmatter. Ownership transfers exactly once, at a deliberate moment, and it never automatically flips back.&lt;/p&gt;

&lt;p&gt;Two small workflows do the actual work. &lt;a href="https://github.com/mattstratton/mattstratton-web/blob/master/.github/workflows/devto-import.yml" rel="noopener noreferrer"&gt;&lt;code&gt;devto-import.yml&lt;/code&gt;&lt;/a&gt; runs on a cron, pulls any newly-published dev.to posts, and opens a PR for review, never touching a file that already has an &lt;code&gt;id&lt;/code&gt;. &lt;a href="https://github.com/mattstratton/mattstratton-web/blob/master/.github/workflows/devto-publish.yml" rel="noopener noreferrer"&gt;&lt;code&gt;devto-publish.yml&lt;/code&gt;&lt;/a&gt; runs on push to files under &lt;code&gt;posts/&lt;/code&gt;, and pushes local edits back to dev.to, writing the &lt;code&gt;id&lt;/code&gt; back on a post's very first publish. Their domains never overlap, which is the entire point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The crosspost pipeline (and yes, this post is going through it)
&lt;/h2&gt;

&lt;p&gt;Once a post is git-owned, I can opt it into crossposting by adding &lt;code&gt;crosspost: true&lt;/code&gt; to its frontmatter, which republishes it as a native entry in mattstratton.com's &lt;code&gt;/writing/&lt;/code&gt; collection and rewrites the dev.to post's &lt;code&gt;canonical_url&lt;/code&gt; to point back at mattstratton.com, via &lt;a href="https://github.com/mattstratton/mattstratton-web/blob/master/scripts/crosspost-devto.ts" rel="noopener noreferrer"&gt;&lt;code&gt;crosspost-devto.ts&lt;/code&gt;&lt;/a&gt;. That flag only gets added after a post already has an &lt;code&gt;id&lt;/code&gt;, meaning it's already live on dev.to. It's a deliberate, later opt-in, never part of the initial draft template. This post is a good example of the "before":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Contain&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Multitudes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Also&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Three&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Git&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Repos)"&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;..."&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;astro&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;webdev&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;showdev&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;id&lt;/code&gt;, no &lt;code&gt;crosspost&lt;/code&gt;, no &lt;code&gt;canonical_url&lt;/code&gt;. Once this is published and I decide it belongs on mattstratton.com too, it'll pick up all three, the same way &lt;code&gt;decouple-release-from-deploy.md&lt;/code&gt; already has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4061454&lt;/span&gt;
&lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-07-03T15:43:16Z"&lt;/span&gt;
&lt;span class="na"&gt;crosspost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://www.mattstratton.com/writing/decouple-release-from-deploy/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The design detail I actually like: this collapses into one atomic commit, not a two-phase publish-then-circle-back dance. mattstratton.com's URL for a crossposted piece is fully deterministic from the filename before any build even happens. dev.to's own article ID is server-assigned and unknowable in advance. So one commit creates the new &lt;code&gt;/writing/&lt;/code&gt; entry and rewrites the dev.to source's &lt;code&gt;canonical_url&lt;/code&gt; in the same diff, and one push correctly triggers both the Netlify deploy and dev.to's existing publish workflow, without anyone babysitting a second step.&lt;/p&gt;

&lt;p&gt;One trap worth naming because it's the kind of thing that looks obviously fine until it isn't: the &lt;code&gt;writing&lt;/code&gt; schema has had an optional &lt;code&gt;canonicalUrl&lt;/code&gt; field since the very first Astro migration commit, for a case that had never actually come up: a native &lt;code&gt;/writing/&lt;/code&gt; entry that's really a copy of something whose true original lives elsewhere, like a mirrored Tiger Data post. Before the crosspost script existed, the only place that field showed up at all was a commented-out line in the placeholder template file, &lt;code&gt;# canonicalUrl: ... # set when cross-posted&lt;/code&gt;. Nobody had ever actually set it on a real post. For a dev.to crosspost, though, mattstratton.com is &lt;em&gt;becoming&lt;/em&gt; the canonical source, the opposite of what that field was reserved for, so the crosspost script has to know to leave it unset on the entries it generates instead of reaching for an already-named field out of habit.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few weird-ass bugs, for flavor
&lt;/h2&gt;

&lt;p&gt;This post is meant to be "here's how it's set up," not "here's a bug report," but a couple of the failures along the way are too good not to mention briefly.&lt;/p&gt;

&lt;p&gt;The best one: the publish workflow started failing with a 422 on any post whose &lt;code&gt;canonical_url&lt;/code&gt; changed after the first publish, with the error body &lt;code&gt;{"error":"Tried to load unspecified class: Time","status":422}&lt;/code&gt;. Root cause was a full YAML round-trip silently dropping quotes from a timestamp that doesn't strictly need them under the JS YAML library's own rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# before the fix (breaks dev.to's Ruby backend)&lt;/span&gt;
&lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-07-03T15:43:16Z&lt;/span&gt;
&lt;span class="c1"&gt;# after (quoted, parses fine on both sides)&lt;/span&gt;
&lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-07-03T15:43:16Z"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Perfectly legal YAML either way, by the JS library's rules. But dev.to's backend is Ruby, and Ruby's YAML parser auto-detects that now-unquoted, ISO-8601-shaped scalar as a native &lt;code&gt;Time&lt;/code&gt; object, which its safe loader then refuses to instantiate. Two implementations of "the same" file format, quietly disagreeing about what a bare string is allowed to become. Fixed by doing a targeted single-line replace on just the &lt;code&gt;canonical_url&lt;/code&gt; line instead of re-serializing the whole file.&lt;/p&gt;

&lt;p&gt;A few smaller ones, quickly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two files edited in a different tool picked up Windows line endings, and &lt;code&gt;crosspost: true\r&lt;/code&gt; parses as the string &lt;code&gt;"true\r"&lt;/code&gt;, not the boolean &lt;code&gt;true&lt;/code&gt;. Those two posts just silently never showed up as eligible.&lt;/li&gt;
&lt;li&gt;A 7-part series had dev.to's actual Series feature turned on for exactly 2 of the 7 posts, predating any of this tooling.&lt;/li&gt;
&lt;li&gt;Rewriting internal links across that same series hit a substring-replace bug where a typo'd slug happened to be a literal prefix of a different, correct slug. Fixed by sorting replacements longest-slug-first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The fun stuff: memory and gains
&lt;/h2&gt;

&lt;p&gt;Two smaller pieces worth a callout because they're genuinely neat and don't fit anywhere else in this tour.&lt;/p&gt;

&lt;p&gt;The speaking site's Claude Code skills (adding a new talk, cleaning up a transcript, resyncing talk memory) don't need &lt;a href="https://memory.build" rel="noopener noreferrer"&gt;memory.build&lt;/a&gt; to work. They ran fine without it. But they now also include it, a permanent memory layer for AI agents, so that when I'm working with an agent on content, it can pull up what it already knows about a talk or a previous session instead of me re-explaining context every single time. Nice to have, not load-bearing.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;/fitness&lt;/code&gt; pulls my workout history straight from &lt;a href="https://www.liftosaur.com" rel="noopener noreferrer"&gt;Liftosaur&lt;/a&gt;'s API at build time. The reason it exists at all: Liftosaur's own public profile page just didn't show as much as I wanted, no real trend view, nothing I could link people to that told the whole story. Liftosaur doesn't hand back tidy JSON for a workout either; it returns a compact Liftoscript-text blob, so a small parser extracts the date, program, exercises, and sets from that text. Personal records and trend sparklines get computed from that parsed data at render time, not fetched as a separate call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still on my plate
&lt;/h2&gt;

&lt;p&gt;The crosspost trigger right now is a manual CLI command I have to remember to run. The fix I actually want: since the dev.to workflow is already entirely PR-centric (import arrives as a PR, &lt;code&gt;id&lt;/code&gt; gets written back as an auto-commit on that same push, never a second spawned PR), the crosspost step should follow the exact same shape. A GitHub Action on &lt;code&gt;pull_request&lt;/code&gt; that detects &lt;code&gt;crosspost: true&lt;/code&gt;, generates the &lt;code&gt;/writing/&lt;/code&gt; entry, and pushes it as an additional commit onto the same PR, instead of a manual script run or a redundant second PR. I filed &lt;a href="https://github.com/mattstratton/mattstratton-web/issues/53" rel="noopener noreferrer"&gt;an issue for it&lt;/a&gt; while writing this post, because I'd apparently been assuming for a while that one already existed and it hadn't.&lt;/p&gt;

&lt;p&gt;A couple of others I'm actually looking forward to: &lt;a href="https://github.com/mattstratton/mattstratton-web/issues/51" rel="noopener noreferrer"&gt;a map of everywhere I've spoken&lt;/a&gt; on the speaking site (the event data already has lat/lng, so this should be a fun afternoon, not a project), and &lt;a href="https://github.com/mattstratton/mattstratton-web/issues/41" rel="noopener noreferrer"&gt;real hero and thumbnail image design&lt;/a&gt; for &lt;code&gt;/writing/&lt;/code&gt; and the legacy archive, since the crosspost script currently papers over that gap by embedding the cover image inline in the post body instead of rendering it properly. There's also the usual pile of housekeeping (a &lt;code&gt;master&lt;/code&gt; to &lt;code&gt;main&lt;/code&gt; rename, a Node 24 upgrade) that's real but not exactly a fun read.&lt;/p&gt;

&lt;p&gt;None of it was urgent enough to hold up shipping the parts that already work. That's usually how the good list gets built anyway.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Your Agent Didn't Break Prod. Your Pipeline Did.</title>
      <dc:creator>Matty Stratton</dc:creator>
      <pubDate>Fri, 03 Jul 2026 15:43:16 +0000</pubDate>
      <link>https://dev.to/mattstratton/your-agent-didnt-break-prod-your-pipeline-did-4g9o</link>
      <guid>https://dev.to/mattstratton/your-agent-didnt-break-prod-your-pipeline-did-4g9o</guid>
      <description>&lt;p&gt;Picture a pipeline that looks pretty reasonable on paper. An agent opens pull requests. CI gates the merge to main: lint, tests, build, all green or it doesn't land. A scheduled job periodically promotes whatever's sitting in staging straight to production. No individual approval per feature. Just a batch cutover on a timer.&lt;/p&gt;

&lt;p&gt;That pipeline will absolutely, eventually, wreck your week.&lt;/p&gt;

&lt;p&gt;Not because the agent did anything malicious. Because two very different questions got quietly collapsed into one gate: "did this pass CI" and "is this safe for a real person to see right now." Those have never been the same question, not for humans and not for robots. We just used to have enough friction in the deploy process that the gap between them rarely mattered.&lt;/p&gt;

&lt;p&gt;I've &lt;a href="https://dev.to/mattstratton/how-my-coworker-who-didnt-know-cd-shipped-to-production-3j6j"&gt;written before&lt;/a&gt; about the scaffolding that lets a non-engineer safely drive a coding agent against a real codebase. Rules, skills, hooks. Three layers of paranoia built into the system instead of relying on any one person's vigilance on a random Friday. That post was about the merge gate: what stops garbage from landing on &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Part of that merge gate is worth calling out on its own: a second pass on the same PR, same harness, different system prompt, catches things the first pass missed. You don't need a different vendor or a different model for that to work, just a fresh set of eyes that didn't write the code. &lt;a href="https://www.mattstratton.com/newsletter/was-it-the-model-or-just-a-fresh-pass-uncommitted/" rel="noopener noreferrer"&gt;I wrote more about why that works here.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post is about the gate after that one. Passing CI and landing on &lt;code&gt;main&lt;/code&gt; solves exactly one problem, and it is not the problem of "will my users see something half-finished this afternoon."&lt;/p&gt;

&lt;p&gt;I've been talking about &lt;a href="https://www.youtube.com/watch?v=Qf1-CRT0pvY&amp;amp;t=178s" rel="noopener noreferrer"&gt;shifting things left in the pipeline&lt;/a&gt; for the better part of a decade, usually about security. Same instinct, one checkpoint further down the line: not just "is this code safe to merge," but "is deploying this code, right now, safe."&lt;/p&gt;

&lt;h2&gt;
  
  
  Continuous delivery is not continuous deployment
&lt;/h2&gt;

&lt;p&gt;This isn't a new idea. It's an old one that a lot of teams politely ignored for a decade, mostly because deploys used to be scary enough that nobody wanted to do more than one a sprint.&lt;/p&gt;

&lt;p&gt;Continuous delivery means every merge is &lt;em&gt;releasable&lt;/em&gt;. It does not mean every merge is &lt;em&gt;released&lt;/em&gt;. Continuous deployment collapses those two things into the same action, and that's a choice you make on purpose, not a default you inherit by moving fast.&lt;/p&gt;

&lt;p&gt;If your pipeline treats "merged" and "live" as synonyms, you've opted into continuous deployment without ever deciding to. The fix isn't slowing the pipeline down. It's separating the two decisions so a merge event and a "customers can see this" event stop being the same button.&lt;/p&gt;

&lt;p&gt;Feature flags are one popular way to do that separation, and if you already run one, great, keep running it. But a flag is a mechanism, not the point. I don't run a feature-flag system myself, and I still get the same separation. What matters is the decoupling, not the specific tool you use to get it.&lt;/p&gt;

&lt;p&gt;In case it's not obvious what one even looks like: a feature flag, at its most embarrassingly simple, is a boolean and an &lt;code&gt;if&lt;/code&gt; statement.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;newInvoiceExport&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;renderNewExport&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;renderOldExport&lt;/span&gt;&lt;span class="p"&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 do not need a vendor with a pricing page to get started. A config value, an environment variable, a column on a settings table, all of that counts as day one. Upgrade to something like LaunchDarkly or Unleash once the dumb version is the thing slowing you down, not before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put a gate on the release event, not just the merge event
&lt;/h2&gt;

&lt;p&gt;My own setup looks like this: PRs merge to &lt;code&gt;main&lt;/code&gt;, but &lt;code&gt;main&lt;/code&gt; isn't what's live. A separate release step promotes &lt;code&gt;main&lt;/code&gt; to a &lt;code&gt;production&lt;/code&gt; branch, and that promotion only happens after I explicitly say go. Nothing gets pushed to production on a timer, and nothing gets pushed because a batch of unrelated changes happened to be sitting around.&lt;/p&gt;

&lt;p&gt;Once that promotion kicks off, it isn't done just because Vercel says "build succeeded." The release process waits until Vercel confirms the new build is actually the one serving traffic, then runs an automated check against production itself: hit a handful of endpoints that have to work, confirm they do, and only then call the release finished. If that check fails, I find out. A user does not.&lt;/p&gt;

&lt;p&gt;There's a second, slower layer after that: a manual pass through the specific things that changed in this release, because an automated check can tell you the server responds, not that the feature behaves the way you meant it to.&lt;/p&gt;

&lt;p&gt;None of that requires a flag anywhere. The gate isn't "does this code know how to hide itself." The gate is "does a human, and then a machine, and then a human again, all get a chance to say no before this reaches a real user." Feature flags earn you a &lt;em&gt;fourth&lt;/em&gt; layer, an inside-the-code one, on top of that. Nice to have. Not the load-bearing part.&lt;/p&gt;

&lt;h2&gt;
  
  
  An automated check is monitoring with a head start
&lt;/h2&gt;

&lt;p&gt;I've spent a lot of years on the incident response and observability side of this industry, enough to have &lt;a href="https://speaking.mattstratton.com/talk/the-proactive-approach-data-driven-observability-incident-response" rel="noopener noreferrer"&gt;given entire talks on it&lt;/a&gt;. The lesson that refuses to go out of style: an automated check after a release doesn't exist to catch every possible bad deploy. It exists to catch the &lt;em&gt;slow&lt;/em&gt; discovery of a bad deploy.&lt;/p&gt;

&lt;p&gt;A user hitting your bug is monitoring too. It's just the worst possible kind, because you find out from a support ticket instead of a dashboard, and by the time the ticket lands, it's already been somebody's bad afternoon for a while.&lt;/p&gt;

&lt;p&gt;A post-deploy check that hits the handful of things that actually matter isn't a replacement for real observability: dashboards, alerts, tracing, whatever you've already built. It's a compressed version of the same instinct, aimed at the exact moment you're most likely to have broken something, which is right after you changed something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fallback: revert fast, and don't paint yourself into a corner
&lt;/h2&gt;

&lt;p&gt;A release gate handles the "I'm not ready" case. It doesn't handle the "I was wrong" case, where the promotion went through, real users saw it, and it turns out there's a bug that only shows up under actual traffic. For that you need the other half: the ability to revert fast, which mostly comes down to one boring rule.&lt;/p&gt;

&lt;p&gt;Don't ship a database migration that the previous version of your code can't survive.&lt;/p&gt;

&lt;p&gt;This is the expand/contract pattern, and if you've been doing this long enough you've relearned it under a new name roughly every three years. Add the new column nullable. Backfill it. Write to both the old and new column. Read from the new one once you trust it. Only then, in a later release, drop the old column. Every step in that sequence is safe to roll back from, because the previous version of the app never depends on something that doesn't exist yet.&lt;/p&gt;

&lt;p&gt;Skip that discipline and your revert button becomes decorative. You can &lt;code&gt;git revert&lt;/code&gt; the code in about four seconds. If the migration already ran and already dropped a column the old code expects, reverting the code just gives you a fancier way to be down.&lt;/p&gt;

&lt;p&gt;This is also, not coincidentally, &lt;a href="https://speaking.mattstratton.com/talk/don-t-panic-effective-incident-response" rel="noopener noreferrer"&gt;incident response 101&lt;/a&gt;. Effective incident response runs on having a small number of pre-agreed moves you can execute without a meeting: revert, roll back, page someone, whatever applies. The worst possible moment to invent your rollback plan is during the incident it's supposed to fix. If reverting requires an engineer to reason live about which of three interdependent migrations is safe to undo, you don't have a rollback plan. You have a discussion topic, and discussion topics don't resolve outages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Someone still has to own saying "go"
&lt;/h2&gt;

&lt;p&gt;None of the automation above changes who's accountable for a release. It changes what that person is accountable &lt;em&gt;with&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I've talked before, on stages instead of on a blog, about how &lt;a href="https://speaking.mattstratton.com/talk/the-lifecycle-of-a-service" rel="noopener noreferrer"&gt;the lifecycle of a service&lt;/a&gt; doesn't end the moment something reaches production, and ownership doesn't evaporate the second it ships. Somebody owns a service in production the same way somebody owns the decision to promote a build to it. If your team can't answer "who decided to release this" for a given deploy, an agent that opens PRs quickly is not actually your biggest problem.&lt;/p&gt;

&lt;p&gt;In my setup, that person is me, for now. It might be someone else on my team next month, and the gate doesn't care which name is attached, only that a name is. The promotion step has an owner. It is never "whichever cron job happened to fire at 3am."&lt;/p&gt;

&lt;h2&gt;
  
  
  Same guardrails, new reason to need them
&lt;/h2&gt;

&lt;p&gt;None of this is agent-specific. Gated promotions, expand/contract migrations, decoupling deploy from release: this is stuff engineering orgs have known for well over a decade. It got treated as optional at a lot of small teams because a human was in the loop on every deploy, and a human noticing "wait, this doesn't feel done yet" was doing the job the gate should have been doing.&lt;/p&gt;

&lt;p&gt;Take that human out of the loop, or just make them faster and more numerous by handing them an agent, and the discipline you skipped stops being optional. It was never really optional. It was just being covered for by somebody's judgment call at 4:58pm on a Thursday.&lt;/p&gt;

&lt;p&gt;The agent didn't create this problem. It just removed the friction that used to hide it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Same as last time: if you want the receipts on how the merge-side guardrails work, &lt;a href="https://dev.to/mattstratton/how-my-coworker-who-didnt-know-cd-shipped-to-production-3j6j"&gt;that's here&lt;/a&gt;. This one's the other half.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>ai</category>
      <category>webdev</category>
      <category>cicd</category>
    </item>
    <item>
      <title>What's Actually in My .zshrc (and Why)</title>
      <dc:creator>Matty Stratton</dc:creator>
      <pubDate>Wed, 01 Jul 2026 15:39:44 +0000</pubDate>
      <link>https://dev.to/mattstratton/whats-actually-in-my-zshrc-and-why-53oe</link>
      <guid>https://dev.to/mattstratton/whats-actually-in-my-zshrc-and-why-53oe</guid>
      <description>&lt;p&gt;Your shell config is mostly a graveyard. A line you added in 2019 to fix a problem you don't remember having. An alias you're afraid to remove because what if you need it. Mine is no different, but there are a handful of pieces in there that are worth explaining, not because they're clever, but because most people have never thought about why they'd want them.&lt;/p&gt;

&lt;p&gt;This isn't a full dump of my config. If you want that, it's all on GitHub at &lt;a href="https://github.com/mattstratton/matty-dotfiles" rel="noopener noreferrer"&gt;matty-dotfiles&lt;/a&gt;. This post is about the config itself: the logic, the functions, the stuff that changes how the shell behaves depending on who, or what, is actually running it. (There's a quick note at the bottom on how the files are actually organized, if you're wondering why I keep saying "files" instead of "file.")&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually matters: AGENT_MODE
&lt;/h2&gt;

&lt;p&gt;Here's the thing that made me want to write this post in the first place.&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$npm_config_yes&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CI&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$-&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;i&lt;span class="k"&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;then
  &lt;/span&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AGENT_MODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AGENT_MODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false
&lt;/span&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your shell used to have one audience: you. That's not true anymore. If you're running Claude Code, or any other coding agent that shells out to run commands on your behalf, your shell now has a second audience that doesn't want the same things you do. It doesn't want &lt;code&gt;thefuck&lt;/code&gt; to interactively suggest a correction. It doesn't want a confirmation prompt before &lt;code&gt;rm&lt;/code&gt; deletes something. It doesn't want your fancy multi-line prompt burning tokens or confusing a parser.&lt;/p&gt;

&lt;p&gt;So I detect it. If &lt;code&gt;npm_config_yes&lt;/code&gt; is set, or &lt;code&gt;CI&lt;/code&gt; is set, or the shell isn't interactive (&lt;code&gt;$-&lt;/code&gt; doesn't contain &lt;code&gt;i&lt;/code&gt;), I flip &lt;code&gt;AGENT_MODE&lt;/code&gt; on. Later in &lt;code&gt;.zshrc_shared&lt;/code&gt;, that flag does real work:&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AGENT_MODE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;EDITOR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'nvim'&lt;/span&gt;
  &lt;span class="nv"&gt;PROMPT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'%n@%m:%~%# '&lt;/span&gt;
  &lt;span class="nv"&gt;RPROMPT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt;
  unsetopt CORRECT
  unsetopt CORRECT_ALL
  setopt NO_BEEP
  setopt NO_HIST_BEEP
  setopt NO_LIST_BEEP
  &lt;span class="nb"&gt;alias rm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'rm -f'&lt;/span&gt;
  &lt;span class="nb"&gt;alias cp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'cp -f'&lt;/span&gt;
  &lt;span class="nb"&gt;alias mv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'mv -f'&lt;/span&gt;
  &lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;npm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'npm --no-fund --no-audit'&lt;/span&gt;
  &lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;yarn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'yarn --non-interactive'&lt;/span&gt;
  &lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;pip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'pip --quiet'&lt;/span&gt;
  &lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;git&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'git -c advice.detachedHead=false'&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No beeps, no interactive confirmations, a boring flat prompt instead of starship, and force flags on the destructive file commands so an agent doesn't get stuck at a prompt it can't answer. &lt;code&gt;EDITOR&lt;/code&gt; switches to plain &lt;code&gt;nvim&lt;/code&gt; instead of &lt;code&gt;code --wait&lt;/code&gt;, because a headless agent trying to wait on VS Code to close a file is not a fun afternoon.&lt;/p&gt;

&lt;p&gt;I'd bet a decent chunk of you reading this have never thought about the fact that your interactive shell defaults are actively hostile to a non-interactive process. Worth fixing before it bites you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets that don't live in plaintext
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source&lt;/span&gt; ~/keychain-environment-variables.sh

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;keychain-environment-variable GITHUB_TOKEN&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CHANGELOG_GITHUB_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;keychain-environment-variable GITHUB_TOKEN&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;BOWIE_GITHUB_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;keychain-environment-variable GITHUB_TOKEN&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;HOMEBREW_GITHUB_API_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;keychain-environment-variable GITHUB_TOKEN&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GITHUB_PERSONAL_ACCESS_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;keychain-environment-variable GITHUB_TOKEN&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;BUTTONDOWN_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;keychain-environment-variable BUTTONDOWN_API_KEY&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;keychain-environment-variable&lt;/code&gt; is a function that pulls a secret out of macOS's Keychain at shell startup instead of having it sit around in plaintext in a dotfile that's synced to GitHub. I did not write this myself, I adapted it from &lt;a href="https://www.netmeister.org/blog/keychain-passwords.html" rel="noopener noreferrer"&gt;a blog post&lt;/a&gt;, and I only know that because the comment at the top of the file still says so. It just shells out to &lt;code&gt;security find-generic-password&lt;/code&gt; under the hood, nothing exotic, but it means the thing I &lt;code&gt;git push&lt;/code&gt; never has a credential in it.&lt;/p&gt;

&lt;p&gt;It's worth calling out that this function has to live somewhere. It's not in &lt;code&gt;.zshrc_shared&lt;/code&gt; itself, it's a separate file (&lt;code&gt;keychain-environment-variables.sh&lt;/code&gt;) that gets sourced, and yes, it's yadm-alternated too (&lt;code&gt;##os.Darwin&lt;/code&gt; and &lt;code&gt;##os.Linux&lt;/code&gt; versions, same symlink dance as the zshrc files). If you go copy the alias/export lines above without also grabbing that script, none of this works. Ask me how I know.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;gh&lt;/code&gt; alias that fixes a dumb problem
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;gh&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"GITHUB_TOKEN= command gh"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Small, but it earns its place. The &lt;code&gt;gh&lt;/code&gt; CLI has its own auth flow, and it checks &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; first if it's set, which it is, everywhere, because half my other tools need it exported. Without this alias, &lt;code&gt;gh&lt;/code&gt; quietly uses that token instead of my actual &lt;code&gt;gh auth login&lt;/code&gt; session, and I get weird permission mismatches that take way too long to debug. This alias just unsets the variable for the one command that has opinions about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A shortcut for working an issue with Claude Code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;issue&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; claude &lt;span class="nt"&gt;--permission-mode&lt;/span&gt; plan &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"issue-&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"/implement issue #&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Type &lt;code&gt;issue 412&lt;/code&gt; and it spins up Claude Code in plan mode, in its own worktree named &lt;code&gt;issue-412&lt;/code&gt;, already pointed at a slash command that implements GitHub issue #412. It's a one-liner, but it's the kind of one-liner that removes enough friction that I actually use it instead of doing the same five commands by hand every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  atuin and starship deserve more than a name-drop
&lt;/h2&gt;

&lt;p&gt;I mentioned in &lt;a href="https://dev.to/mattstratton/my-brewfile-1pob"&gt;My Brewfile&lt;/a&gt; that I install both of these, but I glossed over why, so let's fix that.&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="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;atuin init zsh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://atuin.sh/" rel="noopener noreferrer"&gt;atuin&lt;/a&gt; replaces your plain old shell history file with a searchable SQLite database, and it hooks into your shell so every command gets logged with context: what directory you were in, how long it ran, whether it exited cleanly. The part that actually changed how I work is the search. Instead of mashing the up arrow forty times or grepping through &lt;code&gt;.zsh_history&lt;/code&gt; and hoping, I hit a keybinding and fuzzy-search across every command I've ever run, filtered by directory if I want. If you sync it, that history follows you across machines too. I didn't realize how much time I spent reconstructing commands from memory until I stopped having to.&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="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;starship init zsh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://starship.rs/" rel="noopener noreferrer"&gt;starship&lt;/a&gt; is the prompt. That's it, that's the whole job, but it does that job well: git branch and status, language versions, command duration, all rendered fast and only when relevant, so it doesn't clutter the prompt with things that don't apply to the directory you're in. The reason it matters more than "cosmetic" makes it sound is that a good prompt answers questions before you have to ask them. Am I in a dirty git tree? Which Node version is active here? Starship tells me before I type anything.&lt;/p&gt;

&lt;p&gt;Also notice that starship shows back up in the &lt;code&gt;AGENT_MODE&lt;/code&gt; block from earlier, sort of. When agent mode is on, I explicitly override &lt;code&gt;PROMPT&lt;/code&gt; to something flat and boring instead of letting starship render. Fancy prompts are a UX win for a human; they're just noise (or worse, a parsing hazard) for a process reading your terminal output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Aliases are underrated, actually
&lt;/h2&gt;

&lt;p&gt;People treat aliases like a beginner's trick, something you graduate out of once you "really" know the shell. That's backwards. An alias is just muscle memory you get to define yourself, for the commands you actually run, not the ones a tutorial thinks you should run. A few from my shared config:&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="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;c&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'clear'&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;sz&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'source ~/.zshrc'&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;zshconfig&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"nvim ~/.zshrc"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;gpom&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"git push origin main"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;gitmain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"git checkout main &amp;amp;&amp;amp; git pull origin main"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;cpenv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'cp ~/src/github.com/timescale/tiger-den/.env ./'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;c&lt;/code&gt; has been in my shell configs for so many decades at this point that I genuinely forget, in the moment, that &lt;code&gt;c&lt;/code&gt; isn't actually a real command. It's &lt;code&gt;clear&lt;/code&gt;. It has always been &lt;code&gt;clear&lt;/code&gt;. I have simply stopped knowing that.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sz&lt;/code&gt; and &lt;code&gt;zshconfig&lt;/code&gt; exist because I edit and reload this file constantly, and typing &lt;code&gt;nvim ~/.zshrc##os.Darwin&lt;/code&gt; correctly every time is not a skill I've bothered to develop. &lt;code&gt;gpom&lt;/code&gt; and &lt;code&gt;gitmain&lt;/code&gt; are the ones I'd actually recommend you steal: they encode the exact git incantations you run dozens of times a day into something you can type without thinking. &lt;code&gt;cpenv&lt;/code&gt; is hyper-specific to one project of mine, and that's fine too. Aliases don't need to be generalizable to be worth having.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stuff I'll just mention
&lt;/h2&gt;

&lt;p&gt;A few plugins and settings that do work but don't need a full section: &lt;code&gt;thefuck&lt;/code&gt; for command correction, &lt;code&gt;autojump&lt;/code&gt; for directory jumping, oh-my-zsh plugins like &lt;code&gt;git-extras&lt;/code&gt; and &lt;code&gt;history-substring-search&lt;/code&gt;, and a couple of bindkeys so option+arrow jumps by word instead of doing nothing useful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bindkey &lt;span class="s2"&gt;"^[[1;3C"&lt;/span&gt; forward-word
bindkey &lt;span class="s2"&gt;"^[[1;3D"&lt;/span&gt; backward-word
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All small. All the kind of thing you only notice once it's gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: why there are three files, not one
&lt;/h2&gt;

&lt;p&gt;I manage my dotfiles with &lt;a href="https://yadm.io/" rel="noopener noreferrer"&gt;yadm&lt;/a&gt;, which supports "alternate files" keyed on things like OS. So instead of one &lt;code&gt;.zshrc&lt;/code&gt;, I actually have &lt;code&gt;.zshrc##os.Darwin&lt;/code&gt; and &lt;code&gt;.zshrc##os.Linux&lt;/code&gt;, and yadm symlinks the right one to &lt;code&gt;.zshrc&lt;/code&gt; depending on the machine. Both of those source a third file, &lt;code&gt;.zshrc_shared&lt;/code&gt;, which holds everything that doesn't care what kernel it's running on. Same setup applies to the keychain script above.&lt;/p&gt;

&lt;p&gt;That's really it. It's a small mechanism, not a deep one, which is exactly why it's a bonus section and not its own post. If enough people want the full "how yadm alternates work" writeup, say so in the comments and I'll do it properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go poke around
&lt;/h2&gt;

&lt;p&gt;The full files are in &lt;a href="https://github.com/mattstratton/matty-dotfiles" rel="noopener noreferrer"&gt;matty-dotfiles&lt;/a&gt; if you want the parts I skipped. If you've got a shell trick worth stealing, I want to hear about it in the comments.&lt;/p&gt;

</description>
      <category>shell</category>
      <category>zsh</category>
      <category>dotfiles</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Postgres Extensions Cheat Sheet: Replace 7 Databases With SQL</title>
      <dc:creator>Matty Stratton</dc:creator>
      <pubDate>Sat, 02 May 2026 20:47:24 +0000</pubDate>
      <link>https://dev.to/tigerdata/postgres-extensions-cheat-sheet-replace-7-databases-with-sql-ded</link>
      <guid>https://dev.to/tigerdata/postgres-extensions-cheat-sheet-replace-7-databases-with-sql-ded</guid>
      <description>&lt;p&gt;This post is a practical companion to &lt;a href="https://www.tigerdata.com/blog/its-2026-just-use-postgres" rel="noopener noreferrer"&gt;&lt;u&gt;It's 2026, Just Use Postgres&lt;/u&gt;&lt;/a&gt;. That post makes the architectural case for consolidating on Postgres. This one shows you how.&lt;/p&gt;

&lt;p&gt;Below are working SQL examples for each use case. Every extension listed here is available on &lt;a href="https://console.cloud.timescale.com" rel="noopener noreferrer"&gt;&lt;u&gt;Tiger Cloud&lt;/u&gt;&lt;/a&gt; with no additional setup. If you're self-hosting, each section links to the extension's repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you'll be able to do after reading this:&lt;/strong&gt; Set up Postgres extensions for full-text search, vector search, time-series, caching, message queues, document storage, geospatial queries, and scheduled jobs. Each section is self-contained, so you can skip to what you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enable Everything
&lt;/h2&gt;

&lt;p&gt;Here's the full set. You probably don't need all of them. Pick the ones that match your workload.&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="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;pg_textsearch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- BM25 full-text search&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Vector search (pgvector)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;vectorscale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- DiskANN index for vectors&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- AI embeddings and RAG workflows&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;timescaledb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Time-series&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;pgmq&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Message queues&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;pg_cron&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Scheduled jobs&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;postgis&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- Geospatial&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Full-Text Search (Replace Elasticsearch)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Extension:&lt;/strong&gt; &lt;a href="https://github.com/timescale/pg_textsearch" rel="noopener noreferrer"&gt;&lt;u&gt;&lt;code&gt;pg_textsearch&lt;/code&gt;&lt;/u&gt;&lt;/a&gt; (true BM25 ranking)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you're replacing:&lt;/strong&gt; Elasticsearch (separate JVM cluster, complex mappings, sync pipelines), Solr, or Algolia ($1 per 1,000 searches).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; The same BM25 algorithm that powers Elasticsearch, running natively in Postgres. No separate cluster. No sync jobs. No data drift.&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;articles&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;SERIAL&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;title&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;content&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Create a BM25 index&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_articles_bm25&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;bm25&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Search with BM25 scoring&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;title&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="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;@&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'database optimization'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;@&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'database optimization'&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Deep dive:&lt;/strong&gt; &lt;a href="https://www.tigerdata.com/blog/you-dont-need-elasticsearch-bm25-is-now-in-postgres" rel="noopener noreferrer"&gt;&lt;u&gt;You Don't Need Elasticsearch: BM25 is Now in Postgres&lt;/u&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Vector Search (Replace Pinecone)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Extensions:&lt;/strong&gt; &lt;a href="https://github.com/pgvector/pgvector" rel="noopener noreferrer"&gt;&lt;u&gt;&lt;code&gt;pgvector&lt;/code&gt;&lt;/u&gt;&lt;/a&gt; + &lt;a href="https://github.com/timescale/pgvectorscale" rel="noopener noreferrer"&gt;&lt;u&gt;&lt;code&gt;pgvectorscale&lt;/code&gt;&lt;/u&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you're replacing:&lt;/strong&gt; Pinecone ($70/month minimum, separate infrastructure, data sync), Qdrant, Milvus, or Weaviate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; pgvectorscale uses the DiskANN algorithm (from Microsoft Research). On a &lt;a href="https://www.tigerdata.com/blog/pgvector-vs-pinecone" rel="noopener noreferrer"&gt;&lt;u&gt;50M vector benchmark&lt;/u&gt;&lt;/a&gt;, it achieved 28x lower p95 latency and 16x higher throughput than Pinecone at 99% recall.&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="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;vectorscale&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;documents&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;SERIAL&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;content&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;embedding&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- High-performance DiskANN index&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_docs_embedding&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;diskann&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Find similar documents&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'[0.1, 0.2, ...]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'[0.1, 0.2, ...]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Auto-sync embeddings with pgai
&lt;/h3&gt;

&lt;p&gt;No more manual embedding pipelines. pgai regenerates embeddings automatically on every INSERT and UPDATE.&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;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_vectorizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'documents'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;loading&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loading_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'content'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding_openai&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'text-embedding-3-small'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;dimensions&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'1536'&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row stays in sync. No batch jobs. No drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hybrid Search: BM25 + Vectors in One Query
&lt;/h2&gt;

&lt;p&gt;This is where Postgres consolidation pays off immediately. Combining keyword search and semantic search in other stacks requires two API calls, result merging, failure handling, and double the latency. In Postgres, it's one query.&lt;/p&gt;

&lt;h3&gt;
  
  
  Simple weighted hybrid
&lt;/h3&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;title&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="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;@&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'database optimization'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;bm25_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;vector_distance&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;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;*&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="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;@&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'database optimization'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;hybrid_score&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;hybrid_score&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Reciprocal Rank Fusion (for RAG applications)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;bm25&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;@&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;vectors&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;d&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="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="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&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="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;bm25&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;vectors&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One query. One transaction. One result set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Time-Series (Replace InfluxDB)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Extension:&lt;/strong&gt; &lt;a href="https://github.com/timescale/timescaledb" rel="noopener noreferrer"&gt;&lt;u&gt;TimescaleDB&lt;/u&gt;&lt;/a&gt; (21K+ GitHub stars)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you're replacing:&lt;/strong&gt; InfluxDB (separate database, Flux or limited SQL), Prometheus (metrics only, not application data).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; Automatic time-based partitioning, compression up to 95%, continuous aggregates for fast dashboards, and full SQL. Your time-series data lives alongside your relational data with &lt;code&gt;JOIN&lt;/code&gt;s and &lt;a href="https://www.tigerdata.com/learn/understanding-acid-compliance" rel="noopener noreferrer"&gt;&lt;u&gt;ACID guarantees&lt;/u&gt;&lt;/a&gt;.&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="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;timescaledb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;device_id&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;temperature&lt;/span&gt; &lt;span class="nb"&gt;DOUBLE&lt;/span&gt; &lt;span class="nb"&gt;PRECISION&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Convert to a hypertable (automatic time partitioning)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;create_hypertable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'metrics'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'time'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Query with time buckets&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;time_bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'1 hour'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;temperature&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;metrics&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Lifecycle automation
&lt;/h3&gt;

&lt;p&gt;TimescaleDB handles retention and compression policies so you don't have to build cron jobs for data management.&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="c1"&gt;-- Automatically drop data older than 30 days&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;add_retention_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'metrics'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'30 days'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Compress data older than 7 days (up to 95% storage reduction)&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timescaledb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compress&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;add_compression_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'metrics'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Case study:&lt;/strong&gt; &lt;a href="https://www.tigerdata.com/blog/from-4-databases-to-1-how-plexigrid-replaced-influxdb-got-350x-faster-queries-tiger-data" rel="noopener noreferrer"&gt;&lt;u&gt;Plexigrid went from 4 databases to 1&lt;/u&gt;&lt;/a&gt; and got 350x faster queries.&lt;/p&gt;




&lt;h2&gt;
  
  
  Caching (Replace Redis)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Feature:&lt;/strong&gt; &lt;code&gt;UNLOGGED&lt;/code&gt; tables + &lt;code&gt;JSONB&lt;/code&gt; (built into Postgres, no extension needed)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you're replacing:&lt;/strong&gt; Redis for simple key-value caching scenarios.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; In-memory-speed storage without WAL overhead. Good for session data, temporary lookups, and simple caches. No separate service to operate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to keep Redis:&lt;/strong&gt; If you need pub/sub, sorted sets, Lua scripting, or complex data structures, Redis is still the better tool for those specific jobs.&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="c1"&gt;-- UNLOGGED = no WAL overhead, faster writes&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;UNLOGGED&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="k"&gt;cache&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="nb"&gt;TEXT&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;value&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Set with expiration&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="k"&gt;cache&lt;/span&gt; &lt;span class="p"&gt;(&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;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_at&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="s1"&gt;'user:123'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{"name": "Alice"}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 hour'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Get&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;cache&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'user:123'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;-- Schedule cleanup with pg_cron&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cache_cleanup'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'0 * * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;cache&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Message Queues (Replace Kafka)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Extension:&lt;/strong&gt; &lt;a href="https://github.com/tembo-io/pgmq" rel="noopener noreferrer"&gt;&lt;u&gt;&lt;code&gt;pgmq&lt;/code&gt;&lt;/u&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you're replacing:&lt;/strong&gt; Kafka or RabbitMQ for task queues and simple event processing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; A lightweight message queue inside Postgres. Send, receive with visibility timeouts, and delete after processing. Transactional with the rest of your data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to keep Kafka:&lt;/strong&gt; If you need high-throughput event streaming across dozens of services, consumer groups, exactly-once semantics, or multi-datacenter replication, Kafka is purpose-built for that.&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="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;pgmq&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pgmq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'my_queue'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Send a message&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pgmq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'my_queue'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{"event": "signup", "user_id": 123}'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Receive (with 30-second visibility timeout)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pgmq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'my_queue'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Delete after processing&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pgmq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'my_queue'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Alternative: SKIP LOCKED pattern (no extension needed)
&lt;/h3&gt;

&lt;p&gt;For simple job queues, Postgres has a built-in pattern using &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;:&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;jobs&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;SERIAL&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;payload&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Worker claims a job atomically&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Documents (Replace MongoDB)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Feature:&lt;/strong&gt; Native &lt;code&gt;JSONB&lt;/code&gt; (built into Postgres since 2014)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you're replacing:&lt;/strong&gt; MongoDB for document storage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; Schemaless document storage with GIN indexing, plus everything Postgres gives you: ACID transactions, relational &lt;code&gt;JOIN&lt;/code&gt;s, and SQL. No separate database for your "document-shaped" data.&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;users&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;SERIAL&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="k"&gt;data&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Insert a nested document&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;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;data&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="s1"&gt;'{
  "name": "Alice",
  "profile": {"bio": "Developer", "links": ["github.com/alice"]}
}'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Query nested fields&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'profile'&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'bio'&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'profile'&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'bio'&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%Developer%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Index specific JSON fields for fast lookups&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_users_email&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Geospatial (Replace Specialized GIS)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Extension:&lt;/strong&gt; &lt;a href="https://postgis.net/" rel="noopener noreferrer"&gt;&lt;u&gt;PostGIS&lt;/u&gt;&lt;/a&gt; (the industry standard since 2001)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you're replacing:&lt;/strong&gt; Nothing, really. PostGIS is what most specialized GIS tools are built on. It powers OpenStreetMap and has been in production for 24 years.&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="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;postgis&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;stores&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;SERIAL&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;name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;GEOGRAPHY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;POINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4326&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Find stores within 5km&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ST_Distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;location&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ST_MakePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;122&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;37&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;78&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;geography&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;meters&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;stores&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ST_DWithin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;location&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ST_MakePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;122&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;37&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;78&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;geography&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Scheduled Jobs (Replace External Cron)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Extension:&lt;/strong&gt; &lt;a href="https://github.com/citusdata/pg_cron" rel="noopener noreferrer"&gt;&lt;u&gt;&lt;code&gt;pg_cron&lt;/code&gt;&lt;/u&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you're replacing:&lt;/strong&gt; External &lt;code&gt;cron&lt;/code&gt; jobs, Kubernetes CronJobs, or Lambda scheduled triggers for database maintenance tasks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; Cron scheduling inside Postgres. Useful for cache cleanup, materialized view refreshes, data retention, and periodic aggregation.&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="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;pg_cron&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Run cache cleanup every hour&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cleanup'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'0 * * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;cache&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Refresh a materialized view every night at 2 AM&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rollup'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'0 2 * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="n"&gt;REFRESH&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;daily_stats&lt;/span&gt;&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fuzzy Search (Typo Tolerance)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Extension:&lt;/strong&gt; &lt;code&gt;pg_trgm&lt;/code&gt; (built into Postgres)&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="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;pg_trgm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_name_trgm&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="n"&gt;gin_trgm_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Finds "PostgreSQL" even when typed as "posgresql"&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="s1"&gt;'posgresql'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'posgresql'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;If you want the architectural argument for why consolidating on Postgres matters (especially in the AI era), read &lt;a&gt;&lt;u&gt;It's 2026, Just Use Postgres&lt;/u&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;All of these extensions come pre-configured on &lt;a href="https://console.cloud.timescale.com" rel="noopener noreferrer"&gt;&lt;u&gt;Tiger Cloud&lt;/u&gt;&lt;/a&gt;. Create a free database and start building.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Further reading:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.tigerdata.com/docs/use-timescale/latest/extensions/pg-textsearch" rel="noopener noreferrer"&gt;&lt;u&gt;pg_textsearch documentation&lt;/u&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/timescale/pgvectorscale" rel="noopener noreferrer"&gt;&lt;u&gt;pgvectorscale on GitHub&lt;/u&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.tigerdata.com/docs/" rel="noopener noreferrer"&gt;&lt;u&gt;TimescaleDB documentation&lt;/u&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/tembo-io/pgmq" rel="noopener noreferrer"&gt;&lt;u&gt;pgmq on GitHub&lt;/u&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://postgis.net/" rel="noopener noreferrer"&gt;&lt;u&gt;PostGIS&lt;/u&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.tigerdata.com/blog/from-4-databases-to-1-how-plexigrid-replaced-influxdb-got-350x-faster-queries-tiger-data" rel="noopener noreferrer"&gt;&lt;u&gt;How Plexigrid replaced InfluxDB and got 350x faster queries&lt;/u&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>postgresqlextensions</category>
      <category>database</category>
      <category>sql</category>
    </item>
    <item>
      <title>Why I'm Learning ROS 2 as a Database Person</title>
      <dc:creator>Matty Stratton</dc:creator>
      <pubDate>Fri, 01 May 2026 21:46:47 +0000</pubDate>
      <link>https://dev.to/mattstratton/why-im-learning-ros-2-as-a-database-person-3cce</link>
      <guid>https://dev.to/mattstratton/why-im-learning-ros-2-as-a-database-person-3cce</guid>
      <description>&lt;p&gt;There's a moment that happens in every robotics company that makes it to production. The pilot worked. The robot does the thing. Now there are twenty on the floor, then fifty, then a fleet, and somebody asks: "What did the sensors look like on unit 17 during those three anomalies last quarter?"&lt;/p&gt;

&lt;p&gt;That's when the data infrastructure question gets real. As robotics moves from lab to warehouse floor to factory line, that question is going to come up a lot more often. The default answers aren't really built for it.&lt;/p&gt;

&lt;p&gt;I'm not a robotics engineer. Never built a robot. Never written a &lt;a href="https://docs.ros.org/en/jazzy/" rel="noopener noreferrer"&gt;ROS 2&lt;/a&gt; node or had an argument about &lt;a href="https://mcap.dev" rel="noopener noreferrer"&gt;MCAP&lt;/a&gt; vs. SQLite3 (though I'm about to). What I am is someone who's spent 20+ years watching what happens when storage decisions get made before the questions are fully understood. Log files are the classic version: great for appending, bad for answering "show me the error pattern across 200 servers from three weeks ago." The data exists. It's just not queryable. That's the shape of regret I'm talking about.&lt;/p&gt;

&lt;p&gt;I work at &lt;a href="https://tigerdata.com" rel="noopener noreferrer"&gt;Tiger Data&lt;/a&gt; doing developer relations for &lt;a href="https://github.com/timescale/timescaledb" rel="noopener noreferrer"&gt;TimescaleDB&lt;/a&gt;. Time-series data is literally my job. When I started looking at how ROS 2 handles recorded telemetry at scale, I saw something familiar: high-frequency sequential writes, stable append rates, a recording format optimized for replay rather than analysis, no real query story for "compare behavior across 200 runs without loading everything into memory."&lt;/p&gt;

&lt;p&gt;Oof.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;rosbag2&lt;/code&gt; is good at what it does. Record, replay, done. That's the right scope for it. If you've been running ROS 2 long enough to think about what happens &lt;em&gt;after&lt;/em&gt; the bag file, you've probably felt the edge of that scope. The problem is that "done recording" is the beginning of a different problem when you're trying to understand what's happening across a fleet, over time, at scale. There's &lt;a href="https://github.com/ros2/rosbag2/issues/1739" rel="noopener noreferrer"&gt;an open feature request in the rosbag2 repo&lt;/a&gt; that names TimescaleDB and InfluxDB by name as candidate options for exactly this. It's been open since July 2024. Zero comments.&lt;/p&gt;

&lt;p&gt;The community noticed the gap. Nobody filled it.&lt;/p&gt;

&lt;p&gt;So that's what I'm going to try to figure out, in public. I'm going to build a ROS 2 node that writes telemetry directly to TimescaleDB and actually query it. All of it in a &lt;a href="https://github.com/mattstratton/ros2-timescaledb-bridge" rel="noopener noreferrer"&gt;public GitHub repo&lt;/a&gt;, commit by commit, so the decisions are visible as they get made. Document everything that breaks. The database side I know. The ROS 2 side I'm learning from scratch, and I'll be honest about that difference in real time. And to be clear: I don't actually know what any of this looks like at fleet scale. That's not false modesty. That's the whole point.&lt;/p&gt;

&lt;p&gt;What's coming next: getting the local environment set up (&lt;a href="https://emanual.robotis.com/docs/en/platform/turtlebot3/overview/" rel="noopener noreferrer"&gt;TurtleBot3&lt;/a&gt; is basically the hello world of ROS 2 simulation: it's what the official tutorials use, it publishes the standard topics I care about, and &lt;a href="https://gazebosim.org" rel="noopener noreferrer"&gt;Gazebo&lt;/a&gt; runs the whole thing without me needing actual hardware*) and a post on what &lt;code&gt;rosbag2&lt;/code&gt; actually stores and why the format question matters once you're past replay. After that, the actual build: schema decisions, type mapping headaches, and a Grafana dashboard over live robot telemetry. The full tutorial comes last, once I've learned enough to actually teach it.&lt;/p&gt;

&lt;p&gt;If you've ever wanted to weigh in on that &lt;a href="https://github.com/ros2/rosbag2/issues/1739" rel="noopener noreferrer"&gt;rosbag2 feature request&lt;/a&gt;, now's a good time. And if you're working on this problem (fleet telemetry pipelines, robot data at scale, the gap between recording and understanding), I want to hear what you're running into.&lt;/p&gt;




&lt;p&gt;* - that said, I am not opposed to asking my boss to buy me a robot&lt;/p&gt;

</description>
      <category>ros2</category>
      <category>robotics</category>
      <category>timeseries</category>
      <category>database</category>
    </item>
    <item>
      <title>How My Coworker Who Didn't Know 'cd' Shipped to Production</title>
      <dc:creator>Matty Stratton</dc:creator>
      <pubDate>Thu, 23 Apr 2026 20:30:39 +0000</pubDate>
      <link>https://dev.to/mattstratton/how-my-coworker-who-didnt-know-cd-shipped-to-production-3j6j</link>
      <guid>https://dev.to/mattstratton/how-my-coworker-who-didnt-know-cd-shipped-to-production-3j6j</guid>
      <description>&lt;p&gt;This morning, our Design Lead asked me how to get her terminal into the right folder.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tanya [6:57 AM]
my ghost isnt in tiger den anymore! GAH do i do /tiger-den
or what was the command to make it work 
in tiger den

Matty [7:14 AM]
What what?

Tanya [7:14 AM]
sorry lol
you know when i go into terminal/or ghostie
and i need to type the thing to make it so im
working in tiger den

Matty [7:14 AM]
Yes type cd tiger-den

Tanya [7:15 AM]
YES
OK CD
thank you
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://www.linkedin.com/in/tanya-higgins/" rel="noopener noreferrer"&gt;Tanya&lt;/a&gt; has been here at &lt;a href="https://tigerdata.com" rel="noopener noreferrer"&gt;Tiger Data&lt;/a&gt; for about two weeks. In that time, she shipped a feature to our production Next.js app.&lt;/p&gt;

&lt;p&gt;I'm not going to pretend that's a normal sentence to write.&lt;/p&gt;

&lt;p&gt;Tanya is our Design Lead. She runs brand. She's the reason our designs look the way they do and our blog thumbnails don't look like a high school yearbook. She is not a software engineer. She had not, as far as I know, previously planned on becoming one.&lt;/p&gt;

&lt;p&gt;She didn't become one. She just started shipping.&lt;/p&gt;

&lt;p&gt;The feature she shipped is an internal brand hub: a searchable index of every Tiger Data brand asset (logos, typography, colors, whatever you need) plus an interactive image builder that works like Canva (if Canva had been written specifically for our team). You can upload your own images, pick from our logo library, layer in backgrounds and icons, and drop text on top. Drag everything around, resize it, line it up. Export a PNG. (The thumbnail on this post was made with it.)&lt;/p&gt;

&lt;p&gt;The downside of teaching a designer to use the terminal is that she will want hers to look like yours. Tanya saw my &lt;a href="https://ghostty.org/" rel="noopener noreferrer"&gt;Ghostty&lt;/a&gt; theme and my &lt;a href="https://nix.catppuccin.com/options/main/home/catppuccin.starship/" rel="noopener noreferrer"&gt;catppuccin Starship&lt;/a&gt; theme over a screen share and decided she wanted both. Her Claude Code statusline came next. That's an entire other post.&lt;/p&gt;

&lt;p&gt;Hold both of these facts in your brain at the same time: Tanya is asking me what &lt;code&gt;cd&lt;/code&gt; does this morning. Tanya shipped a real feature to a real codebase last week.&lt;/p&gt;

&lt;p&gt;If you read &lt;a href="https://dev.to/mattstratton/coding-agents-are-actually-good-at-this-one-thing-5dej"&gt;my last post on coding agents and internal tooling&lt;/a&gt;, you know where I ended it. The caveat was that I was a solo contributor to a real codebase, and the first comment on the post called out exactly the right thing. &lt;em&gt;Who owns this when you leave?&lt;/em&gt; That's a reasonable question. In March, my answer was "me, and that's a problem."&lt;/p&gt;

&lt;p&gt;My answer in April is "...and also Tanya."&lt;/p&gt;

&lt;p&gt;That's only true because of what got built alongside the agent. The agent is the interesting part of the demo. The &lt;em&gt;boring&lt;/em&gt; part is what made it safe to hand a Next.js codebase to a designer who's still learning &lt;code&gt;cd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's that boring part.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent is not the hard part
&lt;/h2&gt;

&lt;p&gt;Coding agents are very good at a lot of things. Being trustworthy is...not one of them.&lt;/p&gt;

&lt;p&gt;I don't mean malicious. I mean if you ask an agent to fix a bug, it will often fix the bug. It will also sometimes decide the failing test is "probably flaky" and move on. Or notice a test it doesn't understand and quietly skip it. Or hit an error, try a second approach, fail, try a third approach, fail again, and by the time you look up it has deleted the function you were trying to fix. Or my favorite one: "These test failures are unrelated to my changes".&lt;/p&gt;

&lt;p&gt;This is catchable. I catch a lot of it. I've been doing ops since before the Seinfeld finale aired, and I have been running DevOpsDays since before most of the current DevOps job descriptions existed. I read every diff before it leaves my machine and I have strong opinions about what a normal-looking CI run is supposed to contain.&lt;/p&gt;

&lt;p&gt;I still miss things, everyone does. I rubber-stamp, I push tired, I get three levels deep on a task and stop reading carefully. Even with the paranoia cranked up, I am not reliably the last line of defense for my own work, let alone anyone else's.&lt;/p&gt;

&lt;p&gt;And the paranoia itself is not transferable. "Notice when an agent silently dropped a test case" is not a skill you pick up in two weeks. It is not a skill most of the &lt;em&gt;engineers&lt;/em&gt; I know pick up in two weeks, because most of them never had to. I cannot hand it to Tanya by writing a memo.&lt;/p&gt;

&lt;p&gt;So the question is not &lt;em&gt;how do I make Tanya as paranoid as I am&lt;/em&gt;. The question is: how do I make the system so paranoid that it doesn't matter how paranoid any of us are on a given Friday afternoon?&lt;/p&gt;

&lt;p&gt;You build the paranoia into the system. (If this &lt;a href="https://dev.to/mattstratton/shifting-left-securely-with-inspec-6fk"&gt;sounds familiar&lt;/a&gt;, it should.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules are a suggestion
&lt;/h2&gt;

&lt;p&gt;The first place everyone reaches is &lt;code&gt;CLAUDE.md&lt;/code&gt;. Some people call theirs &lt;code&gt;AGENTS.md&lt;/code&gt; if they want the file to be portable across agents that aren't Claude. Same idea. You write down the rules you want the agent to follow, drop it at the root of the repo, and the agent reads it every session.&lt;/p&gt;

&lt;p&gt;Ours has a section called "Golden Rules". A handful of the actual rules from the actual file:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Diagnose before fixing. Read errors, trace code, explain your hypothesis before writing any fix. No guess-and-deploy.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Never dismiss failures. Test failures, CI failures, lint warnings. Investigate every one. Never call them "pre-existing" or "not my problem."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Never destructive without permission. No DROP, TRUNCATE, DELETE without WHERE.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Always use &lt;code&gt;/pr&lt;/code&gt; to push. Never raw &lt;code&gt;git push&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;These are not theoretical. Each one is on the list because the agent did the thing I didn't want it to do, often enough that I eventually wrote it down. (Sometimes from running &lt;a href="https://pasqualepillitteri.it/en/news/408/claude-code-insights-command-workflow" rel="noopener noreferrer"&gt;&lt;code&gt;/insights&lt;/code&gt;&lt;/a&gt; in Claude Code.)&lt;/p&gt;

&lt;p&gt;Does the agent follow them? Mostly. Does the agent ignore them when convenient? Also yes.&lt;/p&gt;

&lt;p&gt;The failure mode is an exchange you have probably already had:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Me: "Wait, you just pushed to main."&lt;/p&gt;

&lt;p&gt;Claude: "Oh shoot, you're right. The &lt;code&gt;CLAUDE.md&lt;/code&gt; says to use &lt;code&gt;/pr&lt;/code&gt;. I just... didn't."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It apologizes. It means it. It will still do it again on Tuesday.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; is training; it shapes the default behavior. It does &lt;em&gt;not&lt;/em&gt; enforce anything. Treat it like training and it is useful. Treat it like a wall and something is going to walk through the wall.&lt;/p&gt;

&lt;p&gt;That was the first thing I had to learn. It might have take a few times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skills are a habit
&lt;/h2&gt;

&lt;p&gt;If rules are what you write down when you want an agent to &lt;em&gt;know&lt;/em&gt; something, skills are what you write down when you want the agent (or the human) to &lt;em&gt;do&lt;/em&gt; something the same way every time.&lt;/p&gt;

&lt;p&gt;A Claude Code skill is roughly: a folder with instructions, some scripts, and a slash command. Type &lt;code&gt;/setup&lt;/code&gt; and it walks through our dev environment setup. Type &lt;code&gt;/debug&lt;/code&gt; and it starts a structured investigation instead of letting the agent flail. Type &lt;code&gt;/release&lt;/code&gt; and it runs the release process in the right order.&lt;/p&gt;

&lt;p&gt;The one that matters most is &lt;code&gt;/pr&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Our repo has a preflight checklist you have to pass to open a pull request. It runs lint. It runs the typechecker. It runs the tests. It runs the build. It runs a code review subagent. If any changed files touch rendered output, it runs a UI verification step. If any touched docs, it runs a docs-drift check. If any touched security-sensitive paths, it runs a security review. Only then does it actually open the PR, and even then it opens it as a draft.&lt;/p&gt;

&lt;p&gt;All of that is one command. Tanya does not have to remember any of it. She types &lt;code&gt;/pr&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is still not enforcement. Skills are suggestions. A motivated human (or a tired agent, or a confused agent, or an agent being nudged by a tired human) can skip &lt;code&gt;/pr&lt;/code&gt; and push directly, and nothing in the skill itself will stop them (note: the skill does try to tell the agent when it should try to run it, and it usually gets it right...but not always).&lt;/p&gt;

&lt;p&gt;The job of a skill is to make the right way the easy way. &lt;code&gt;/pr&lt;/code&gt; is one command. Running all of those checks by hand is eight. If typing &lt;code&gt;/pr&lt;/code&gt; is easier than doing the alternative, the alternative stops happening. That is most of the game.&lt;/p&gt;

&lt;p&gt;Tanya used &lt;code&gt;/pr&lt;/code&gt; for the brand hub. She did not run lint, typecheck, tests, and build in sequence. She would not have known to! She typed a slash and a word. Everything ran.&lt;/p&gt;

&lt;p&gt;The rest is between her and the agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hooks are a wall
&lt;/h2&gt;

&lt;p&gt;The rule "never push to main" lives in &lt;code&gt;CLAUDE.md&lt;/code&gt;. The skill &lt;code&gt;/pr&lt;/code&gt; is easier to type than the alternative. Neither of those is enforcement.&lt;/p&gt;

&lt;p&gt;The enforcement is a pre-push &lt;a href="https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks" rel="noopener noreferrer"&gt;git hook&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you (or a coding agent) run &lt;code&gt;git push&lt;/code&gt; from a machine with our repo checked out, the hook fires before the push leaves your laptop. If the branch you are on is &lt;code&gt;main&lt;/code&gt; or &lt;code&gt;production&lt;/code&gt;, the hook exits 1 and prints the error message I wrote specifically for the version of me that is about to do a dumb thing. The push does not happen.&lt;/p&gt;

&lt;p&gt;If the branch is anything else, the hook runs lint, typecheck, tests, and build locally. If any of them fail, the push does not happen.&lt;/p&gt;

&lt;p&gt;There is a flag to skip hooks, yes. If the agent tries to use it, a separate &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;PreToolUse&lt;/code&gt; hook&lt;/a&gt; catches the command before it runs and blocks it on protected branches. And if a push somehow lands on the remote anyway, GitHub branch protection rejects it on the server side.&lt;/p&gt;

&lt;p&gt;I did not build all three layers on day one. I built them in the order I needed them, which is to say I built each one the first time something got past the layer before it.&lt;/p&gt;

&lt;p&gt;There is also a &lt;code&gt;SessionStart&lt;/code&gt; hook that runs before the agent does anything. It checks that the Node version is the one the app expects, that the environment file exists, that dependencies are up to date, that the database the session is about to touch is the development database and not production. If any of that is wrong, the session opens with a warning, and the agent is told to act on the warning before doing anything else.&lt;/p&gt;

&lt;p&gt;None of this requires the agent to cooperate. None of it requires the human to remember.&lt;/p&gt;

&lt;p&gt;CI is the outer wall. Six jobs, parallel, on every pull request. Lint. Typecheck. Frontend tests. Backend tests across three shards. Build. Doc drift check. If any job fails, the PR cannot merge. If the PR does not have a label, a seventh job fails. &lt;strong&gt;Zero warnings allowed&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is the only layer that does not care how tired anyone is, how confused the agent is, or what quiet thing got dropped on the way through. It just says no.&lt;/p&gt;

&lt;p&gt;Tanya has never pushed to &lt;code&gt;main&lt;/code&gt;. She could not push to &lt;code&gt;main&lt;/code&gt; if she tried. Neither can I. We have confirmed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scaffolding is the product
&lt;/h2&gt;

&lt;p&gt;Here is the funny thing about all of this: none of it is new.&lt;/p&gt;

&lt;p&gt;Linters. CI. Pre-commit hooks. Code review. Required labels. Branch protection. Engineering teams have been writing these guardrails for the humans on the team for two decades. What changed is that the same guardrails now work on the agent. And because the agent does most of the typing, they work for the non-engineer driving the agent too.&lt;/p&gt;

&lt;p&gt;The three layers are just discipline given shape. Rules tell the agent what you want, skills make the right thing the easiest thing, hooks enforce the non-negotiables. That stack is not new. It is what engineering has been doing for twenty years, and it works.&lt;/p&gt;

&lt;p&gt;What &lt;em&gt;did&lt;/em&gt; change is the cost. A &lt;code&gt;CLAUDE.md&lt;/code&gt; is an afternoon. A skill is another afternoon. A git hook is an hour. A &lt;code&gt;SessionStart&lt;/code&gt; script is an hour. Most of them I wrote in a single week, when the alternative was cleaning up after a mistake the agent had already made twice.&lt;/p&gt;

&lt;p&gt;The agent isn't the product, the scaffolding is. The agent is really good at working inside it without stepping on anything important.&lt;/p&gt;

&lt;p&gt;There is one piece of scaffolding I have not been able to write.&lt;/p&gt;

&lt;p&gt;It is the call on my calendar every few days where Tanya and I hop on a screen share and work through something hand-in-hand. We have a name for these. It is "make Tanya an engineer." Sometimes we are making her Claude Code statusline look cool. Sometimes we are picking out a new text editor and making it hers. Sometimes we are walking through how the app is put together so we can figure out how to make it even more awesome.&lt;/p&gt;

&lt;p&gt;They are still happening. They will probably still be happening in six months. I am not trying to automate them away.&lt;/p&gt;

&lt;p&gt;There is something about a real person on the other side of a screen share that makes the work feel survivable. A &lt;code&gt;CLAUDE.md&lt;/code&gt; will not do that. A slash command will not do that. The hooks definitely will not do that.&lt;/p&gt;

&lt;p&gt;That is also part of the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things I owe you
&lt;/h2&gt;

&lt;p&gt;First, a callback to &lt;a href="https://dev.to/theminimalcreator/comment/3544p"&gt;Guilherme&lt;/a&gt; on the last post. You were right that Retool is bus-factor insurance. You can also roll your own. It's called a &lt;code&gt;CLAUDE.md&lt;/code&gt;, a pre-push hook that exits 1, and an hour-long call called "make Tanya an engineer."&lt;/p&gt;

&lt;p&gt;Second, a correction on the opener. Tanya is not the only other person shipping code to Tiger Den. A few other folks have been committing too. I made her the hero because her two-week arc was the cleanest story. Apologies to the rest of the team for the dramatic license.&lt;/p&gt;

&lt;p&gt;Airtable, it's been real. Tiger Den is better. Tanya is proof.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow-up, July 2026: everything above is the merge gate; what keeps garbage off &lt;code&gt;main&lt;/code&gt;. There's a second gate after it, between "merged" and "live," and skipping that one will wreck your week in an entirely different way. &lt;a href="https://dev.to/mattstratton/your-agent-didnt-break-prod-your-pipeline-did-4g9o"&gt;That's the other half.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was reviewed and approved by Tanya before publishing. Zero AI agent skills involved.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>devtools</category>
    </item>
    <item>
      <title>How Do PostgreSQL Indices Work, Anyways?</title>
      <dc:creator>Matty Stratton</dc:creator>
      <pubDate>Wed, 18 Mar 2026 14:35:21 +0000</pubDate>
      <link>https://dev.to/tigerdata/how-do-postgresql-indices-work-anyways-3jnn</link>
      <guid>https://dev.to/tigerdata/how-do-postgresql-indices-work-anyways-3jnn</guid>
      <description>&lt;p&gt;You've probably created a hundred indexes in your career. Maybe a thousand. You ran &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;, saw "Index Scan" instead of "Seq Scan," pumped your fist, and moved on.&lt;/p&gt;

&lt;p&gt;But do you actually know what's happening underneath? Because once you do, a lot of things about PostgreSQL performance start to make a &lt;em&gt;lot&lt;/em&gt; more sense. And some of the pain points you've been fighting start to feel less like mysteries and more like, well, physics.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's a tree. Obviously.
&lt;/h2&gt;

&lt;p&gt;The default index type in PostgreSQL is a B-tree. You knew that. But let's talk about what that actually means for your data.&lt;/p&gt;

&lt;p&gt;When you create an index on, say, a &lt;code&gt;timestamp&lt;/code&gt; column, PostgreSQL builds a balanced tree structure where each node contains keys and pointers. The leaf nodes point to actual heap tuples (your rows on disk). The internal nodes just help you navigate. Think of it like a phone book. (Do people still know what phone books are? I'm aging myself.)&lt;/p&gt;

&lt;p&gt;The key thing to understand: the index is a &lt;em&gt;separate data structure&lt;/em&gt; from your table. It lives in its own pages on disk. When you insert a row, PostgreSQL doesn't just write your row. It also has to update every index on that table. Every. Single. One.&lt;/p&gt;

&lt;p&gt;So if you have a table with five indexes and you're doing 50,000 inserts per second, that's not 50K write operations. That's 250K+ B-tree insertions per second, plus the heap write. Oof.&lt;/p&gt;

&lt;p&gt;You can see exactly how much space each index is consuming with &lt;code&gt;\di+&lt;/code&gt; in psql:&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="err"&gt;\&lt;/span&gt;&lt;span class="n"&gt;di&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;

&lt;span class="c1"&gt;-- Or if you want programmatic access:&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;index_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;index_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;idx_scan&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;times_used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;idx_tup_read&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tuples_read&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;pg_stat_user_indexes&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that on your biggest table. If you see indexes measured in gigabytes that have &lt;code&gt;idx_scan = 0&lt;/code&gt;, those indexes are costing you writes and giving you nothing back. They're dead weight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pages, not rows
&lt;/h2&gt;

&lt;p&gt;Here's where it gets interesting. PostgreSQL doesn't read individual rows from disk. It reads 8KB pages. Always. Even if you only want one tiny row, you're pulling in a full 8KB page.&lt;/p&gt;

&lt;p&gt;Your B-tree is also organized into 8KB pages. Each page holds as many index entries as it can fit. For a simple index on a &lt;code&gt;bigint&lt;/code&gt; column, you can fit a few hundred entries per page. For a compound index on &lt;code&gt;(tenant_id, event_type, created_at)&lt;/code&gt;, you're fitting fewer because each entry is wider.&lt;/p&gt;

&lt;p&gt;When PostgreSQL traverses your B-tree, it starts at the root page, reads it, follows a pointer to the right internal page, reads that, and eventually gets to a leaf page that tells it where your actual row lives on the heap. For a table with a million rows, that's maybe three or four page reads. For a billion rows, it might be five or six. Logarithmic scaling is your friend here.&lt;/p&gt;

&lt;p&gt;You can see this in action with &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;:&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;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'1 hour'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Look for lines like:&lt;/span&gt;
&lt;span class="c1"&gt;--   Index Scan using events_created_at_idx on events&lt;/span&gt;
&lt;span class="c1"&gt;--     Buffers: shared hit=4 read=2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;shared hit&lt;/code&gt; count tells you how many pages came from the buffer cache. The &lt;code&gt;read&lt;/code&gt; count tells you how many had to come from disk. If you're seeing high &lt;code&gt;read&lt;/code&gt; values on a query you run frequently, your working set has outgrown your &lt;code&gt;shared_buffers&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But. (There's always a but.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody thinks about
&lt;/h2&gt;

&lt;p&gt;Those leaf pages need to stay ordered. When you insert a new value that belongs in the middle of a page that's already full, PostgreSQL has to split that page. Page splits are expensive. They cause write amplification and can fragment your index over time.&lt;/p&gt;

&lt;p&gt;For time-series data (timestamps always increasing), you mostly dodge this problem because new values go to the rightmost leaf. That's nice. But it creates a different problem: hot-page contention. Every concurrent insert is fighting to write to the same leaf page at the end of the tree.&lt;/p&gt;

&lt;p&gt;And then there's the part that really gets you: MVCC overhead.&lt;/p&gt;

&lt;p&gt;PostgreSQL's multiversion concurrency control means that even your index has to deal with tuple visibility. Index entries don't get removed immediately when a row is deleted or updated. They stick around until &lt;code&gt;VACUUM&lt;/code&gt; cleans them up. So your index isn't just tracking live rows. It's tracking &lt;em&gt;all the versions&lt;/em&gt; of your rows until the cleanup crew gets around to it.&lt;/p&gt;

&lt;p&gt;For a high-churn table, your index can be significantly larger than you'd expect just from the row count. I've seen cases where the index is effectively 2-3x the "expected" size because of dead tuple bloat.&lt;/p&gt;

&lt;p&gt;Here's how to check if bloat is eating your indexes alive:&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;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;n_dead_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;n_live_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_dead_tup&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;nullif&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_live_tup&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n_dead_tup&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;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;dead_pct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;last_autovacuum&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;n_dead_tup&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;n_dead_tup&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;dead_pct&lt;/code&gt; is climbing above 10-20% and &lt;code&gt;last_autovacuum&lt;/code&gt; was hours ago (or null), autovacuum is falling behind. That bloat isn't just wasting space. It's making every index scan touch more pages than it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  Index-only scans (and why they're worth understanding)
&lt;/h2&gt;

&lt;p&gt;There's one more behavior worth knowing about, because it changes how you think about index design.&lt;/p&gt;

&lt;p&gt;Normally, PostgreSQL uses the index to find &lt;em&gt;where&lt;/em&gt; a row lives on the heap, then goes and reads the actual row. That's two separate lookups: the index, then the heap.&lt;/p&gt;

&lt;p&gt;But if every column your query needs is already &lt;em&gt;in&lt;/em&gt; the index, PostgreSQL can skip the heap entirely. That's an index-only scan, and it's significantly faster.&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="c1"&gt;-- This index covers both the WHERE clause and the SELECT list:&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_events_covering&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;INCLUDE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Now this query never touches the heap:&lt;/span&gt;
&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'1 hour'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Look for:&lt;/span&gt;
&lt;span class="c1"&gt;--   Index Only Scan using idx_events_covering on events&lt;/span&gt;
&lt;span class="c1"&gt;--     Heap Fetches: 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Heap Fetches: 0&lt;/code&gt; is what you want. That means PostgreSQL answered the entire query from the index alone.&lt;/p&gt;

&lt;p&gt;The catch: index-only scans only work well when the visibility map is up to date, which brings us right back to VACUUM. If VACUUM hasn't visited a page recently, PostgreSQL can't trust the index alone and has to check the heap anyway. So even this optimization depends on keeping autovacuum healthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Partial indexes (less is more)
&lt;/h2&gt;

&lt;p&gt;One more tool that's underused: partial indexes. If you only query a subset of your data most of the time, you can index just that subset.&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="c1"&gt;-- Instead of indexing every row:&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_events_status&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Index only the rows that matter:&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_events_active&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The partial index is smaller, faster to scan, and cheaper to maintain on writes. For high-churn tables where most queries filter to a small slice of data, this is free performance.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why does this matter?
&lt;/h2&gt;

&lt;p&gt;Understanding this stuff isn't just academic. It explains real problems you hit in production:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why adding indexes slows down writes.&lt;/strong&gt; Every index is another B-tree that needs to be maintained on every insert. It's not free. It's never been free. The cost just hides until you're at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why your queries get slower over time even though nothing changed.&lt;/strong&gt; Index bloat from dead tuples. Pages that used to be tightly packed are now half-empty after splits and vacuuming. Your three-page-read query is now a six-page-read query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why VACUUM matters so much.&lt;/strong&gt; It's not just reclaiming table space. It's keeping your indexes healthy. If autovacuum can't keep up, your indexes degrade. And if you're inserting fast enough, autovacuum can fall behind. That's not a bug. That's just the architecture working as designed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why partitioning helps (and then stops helping).&lt;/strong&gt; Smaller partitions mean smaller indexes mean fewer tree levels. Great. But now your query planner has to evaluate all those partitions to figure out which ones to scan. And that planning cost scales linearly with partition count. You're trading one bottleneck for another.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bigger picture
&lt;/h2&gt;

&lt;p&gt;I wrote about this cycle more extensively in a piece about &lt;a href="https://www.tigerdata.com/blog/postgres-optimization-treadmill?utm_source=devto&amp;amp;utm_medium=da-activity&amp;amp;utm_campaign=matty-digital" rel="noopener noreferrer"&gt;the PostgreSQL optimization treadmill&lt;/a&gt;. The short version: there's a pretty predictable progression that teams go through. Optimize indexes. Partition tables. Tune autovacuum. Scale vertically. Add read replicas. Each phase buys you a few months.&lt;/p&gt;

&lt;p&gt;That's not a criticism of PostgreSQL. Postgres is an incredible database. But it's a &lt;em&gt;general-purpose&lt;/em&gt; relational database, and its architecture reflects that. The heap storage model, MVCC, the query planner, B-trees. They're all designed to handle a wide range of workloads really well. The tradeoff is that for very specific access patterns (like time-series data at scale), those general-purpose design choices start working against you instead of for you.&lt;/p&gt;

&lt;p&gt;Understanding &lt;em&gt;how&lt;/em&gt; your indexes work is the first step to understanding &lt;em&gt;when&lt;/em&gt; they stop being enough. And knowing when you're fighting the architecture instead of optimizing within it can save you months of whack-a-mole performance tuning.&lt;/p&gt;

&lt;p&gt;But that's a topic for another day. For now, go run these queries on your biggest table:&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="c1"&gt;-- How big are your indexes, really?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;index_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;idx_scan&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;scans&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;pg_stat_user_indexes&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your_table_here'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Are any of them unused?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;index_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;idx_scan&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;pg_stat_user_indexes&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;idx_scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You might be surprised.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
      <category>performance</category>
    </item>
    <item>
      <title>Coding Agents Are Actually Good at This One Thing</title>
      <dc:creator>Matty Stratton</dc:creator>
      <pubDate>Sun, 01 Mar 2026 20:31:28 +0000</pubDate>
      <link>https://dev.to/mattstratton/coding-agents-are-actually-good-at-this-one-thing-5dej</link>
      <guid>https://dev.to/mattstratton/coding-agents-are-actually-good-at-this-one-thing-5dej</guid>
      <description>&lt;p&gt;The discourse around AI coding tools tends to collapse into two camps: people who think they're going to replace developers, and people dunking on "vibe coding" demos that fall apart the moment you look at them sideways.&lt;/p&gt;

&lt;p&gt;Both camps are mostly arguing about the wrong thing.&lt;/p&gt;

&lt;p&gt;I've been using coding agents heavily for the past few months, and the use case where they've actually changed how I work isn't production applications or greenfield SaaS ideas. It's internal tooling. And that distinction matters more than people are giving it credit for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Airtable Problem
&lt;/h2&gt;

&lt;p&gt;For years, I built internal tools the way most of us did: I found something close enough and made it work. Airtable was great for this. I built conference talk trackers, content inventories, planning dashboards... all kinds of things. The spreadsheet-meets-database model was genuinely useful, and for a while, it was the right call.&lt;/p&gt;

&lt;p&gt;But you're always fighting two things with tools like Airtable or Notion or even Asana when you're bending them to a purpose they weren't built for. First, you fight the constraints of the tool itself: the data model it has, the views it supports, the automations it allows. Second, you fight the cost and friction of giving your whole team access to yet another SaaS platform.&lt;/p&gt;

&lt;p&gt;At some point the juice stops being worth the squeeze. You're not building what you actually need, you're building the closest approximation that fits inside someone else's product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Just Build the Thing
&lt;/h2&gt;

&lt;p&gt;What coding agents have unlocked for me is the ability to &lt;em&gt;just build the thing&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The project I've been heads-down on is called Tiger Den: an internal content management system for our Marketing team at Tiger Data. It's a Next.js app, Drizzle ORM, tRPC, Postgres (running on &lt;a href="https://tigerdata.com" rel="noopener noreferrer"&gt;Tiger Data&lt;/a&gt;, natch). A real web app with a real database, exactly structured for how we actually work.&lt;/p&gt;

&lt;p&gt;Tiger Den manages our content library, tracks blog posts and videos, stores voice profiles for different authors so we can make sure our content matches our voices, generates UTM links, and surfaces the right content for the right workflow. It's not a product. It's not trying to be. It's a tool that fits our team's exact needs because we built it for our team's exact needs.&lt;/p&gt;

&lt;p&gt;A few folks on the team are starting to use it. Workflows that used to involve a bunch of spreadsheet wrangling and web search gymnastics are getting simpler. It's not fully baked (it's somewhere between "mostly works for me" and "the team is starting to play with it") but it's already more useful than what it replaced (a combination of "nothing" plus "not very updated spreadsheet"), and I've been iterating on it continuously.&lt;/p&gt;

&lt;p&gt;That iteration speed is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Internal Tooling Specifically
&lt;/h2&gt;

&lt;p&gt;The reason coding agents shine here, and why I keep coming back to this use case, is the risk profile.&lt;/p&gt;

&lt;p&gt;When you're building a public-facing application, you have to worry about edge cases at scale, security hardening, performance under load, what happens when a user does something unexpected, what happens when a thousand users do something unexpected. The cost of getting it wrong is high.&lt;/p&gt;

&lt;p&gt;Internal tools have a completely different calculus. The user population is small and known. The stakes for any individual bug are low. You can ship something that's 80% right and fix the other 20% next week. You can move fast and it's actually fine.&lt;/p&gt;

&lt;p&gt;This is the Microsoft Access model, honestly. In the 90s and early 2000s, people built remarkably functional internal tools in Access (forms, reports, relational data, real logic) because the barrier was low enough that you could actually do it. Then the web happened, the complexity went way up, and that era of "just build the thing" basically ended for most people.&lt;/p&gt;

&lt;p&gt;Coding agents are bringing it back. The barrier is low again. You can describe what you want, iterate in a tight loop, and end up with something that actually fits your use case instead of something that almost fits someone else's template.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About Retool?
&lt;/h2&gt;

&lt;p&gt;When I posted about this, someone brought up Retool — fair question. Isn't that exactly what Retool is for?&lt;/p&gt;

&lt;p&gt;Maybe. But my reaction was: isn't that just trading one set of constraints for another? Now I'm stuck on how Retool thinks about the world instead of how Airtable thinks about the world. I haven't dug deep into Retool, so I'm not going to trash it, but the pattern is the same: you're building inside someone else's model of what your tool should be.&lt;/p&gt;

&lt;p&gt;The thing I actually want is to build the thing I want. That's what "just use Next.js and a database" gives me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some Honest Caveats
&lt;/h2&gt;

&lt;p&gt;I want to be careful not to oversell this, because the context matters a lot.&lt;/p&gt;

&lt;p&gt;I'm a systems guy. Infrastructure, security, DevOps, that's my background. I could build Tiger Den without a coding agent; it would just take a lot longer because I'm coming at software development somewhat sideways. The agent collapses that time gap significantly. But I'm still making real architectural decisions, reviewing what gets generated, and understanding the codebase well enough to maintain it. This isn't "describe what you want and walk away."&lt;/p&gt;

&lt;p&gt;Someone also asked about maintenance (a reasonable concern!). My answer: for Tiger Den specifically, I'm only writing to my own database. I'm not doing write operations against external systems, I'm not touching production data, and the blast radius of any given bug is small. That's a deliberate choice, and it's part of why internal tooling is the right framing. If I were building something that talked to our customers' data or integrated deeply with critical external systems, I'd be a lot more careful.&lt;/p&gt;

&lt;p&gt;The low-stakes calculus stops applying the moment you have real users, real consequences for downtime, or data you can't afford to mess up. Keep that boundary clear and this approach works really well. Blur it and you're asking for trouble.&lt;/p&gt;

&lt;p&gt;But for "my team needs a thing and the existing tools don't quite fit"? This is legitimately transformative. I've wanted to be able to build internal tools this easily for a long time. Now I can.&lt;/p&gt;

&lt;p&gt;Airtable, it's been real. Tiger Den is better.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Product 101: Your Secret Weapon for Understanding the Business</title>
      <dc:creator>Matty Stratton</dc:creator>
      <pubDate>Tue, 04 Nov 2025 14:58:55 +0000</pubDate>
      <link>https://dev.to/mattstratton/product-101-your-secret-weapon-for-understanding-the-business-2m6j</link>
      <guid>https://dev.to/mattstratton/product-101-your-secret-weapon-for-understanding-the-business-2m6j</guid>
      <description>&lt;p&gt;&lt;em&gt;This is Part 5 of my 7-part series on business literacy for DevRel. &lt;a href="https://dev.to/mattstratton/why-business-literacy-matters-for-devrel-and-why-you-cant-skip-this-step-30p1"&gt;Start with Part 1 if you missed it&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We've covered sales, marketing, and finance. But there's one group we haven't talked about yet - and they might be your most valuable partnership in the entire company: &lt;strong&gt;Product&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Not because product managers are inherently better than anyone else (though the good ones are pretty great). But because strategic product leaders sit at the intersection of everything. They see the business from a unique vantage point that can be incredibly valuable to DevRel.&lt;/p&gt;

&lt;p&gt;A good product partner is worth their weight in gold. They're your early warning system, your business intelligence source, and your reality check all rolled into one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Product Actually Does
&lt;/h2&gt;

&lt;p&gt;At the most basic level, product management is about figuring out what to build, why to build it, and making sure it actually gets built.&lt;/p&gt;

&lt;p&gt;But that simple description hides a ton of complexity:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Product managers (PMs) are responsible for:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deciding what features/capabilities to prioritize&lt;/li&gt;
&lt;li&gt;Understanding user needs and market opportunities&lt;/li&gt;
&lt;li&gt;Working with engineering to build the right things&lt;/li&gt;
&lt;li&gt;Collaborating with go-to-market teams (sales, marketing) to position and launch features&lt;/li&gt;
&lt;li&gt;Measuring whether what they built is actually working&lt;/li&gt;
&lt;li&gt;Saying "no" to a lot of things (this is actually most of the job)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Product sits at the center of a bunch of competing pressures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sales wants features that will close deals&lt;/li&gt;
&lt;li&gt;Marketing wants things that will generate buzz&lt;/li&gt;
&lt;li&gt;Engineering wants to build technically interesting things&lt;/li&gt;
&lt;li&gt;Finance wants efficient use of resources&lt;/li&gt;
&lt;li&gt;Customers want their specific problems solved&lt;/li&gt;
&lt;li&gt;Executives want the product to support business strategy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The PM's job is to synthesize all of this and make decisions about what the product should become.&lt;/p&gt;

&lt;h2&gt;
  
  
  Meet the Product Team: Roles You Need to Know
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Chief Product Officer (CPO) / VP of Product&lt;/strong&gt;: Sets the overall product vision and strategy. They're thinking about where the product needs to be in 2-3 years, not just next quarter. They usually report directly to the CEO.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Director of Product / Senior PM&lt;/strong&gt;: Usually owns a major product area or domain. They're thinking strategically about their area while managing other PMs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Product Manager (PM)&lt;/strong&gt;: Owns a specific product or feature area. They work day-to-day with engineering teams to ship features. This is who you'll probably work with most.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Technical Product Manager (TPM)&lt;/strong&gt;: Like a PM but more technical. Often owns platform, API, or developer-facing products. If you work at a developer tools company, TPMs are your people.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Product Marketing Manager (PMM)&lt;/strong&gt;: We talked about these folks in the marketing post, but they sit between product and marketing. They take what product builds and figure out how to position and message it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Product Operations / Product Ops&lt;/strong&gt;: Help PMs be more effective by managing tools, processes, and data. They're the behind-the-scenes folks making product teams run smoothly.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Product Thinks About the World
&lt;/h2&gt;

&lt;p&gt;Product managers live in a few key frameworks. Understanding these helps you speak their language:&lt;/p&gt;

&lt;h3&gt;
  
  
  The Product Roadmap
&lt;/h3&gt;

&lt;p&gt;This is the plan for what gets built when. Roadmaps are usually organized by quarters or releases, and they're always wrong (because priorities change). &lt;/p&gt;

&lt;p&gt;Roadmaps are political documents as much as planning documents. What makes it onto the roadmap and what doesn't tells you a lot about what the business values.&lt;/p&gt;

&lt;h3&gt;
  
  
  User Stories and Jobs to Be Done
&lt;/h3&gt;

&lt;p&gt;PMs think about user needs in terms of "&lt;a href="https://www.atlassian.com/agile/project-management/user-stories" rel="noopener noreferrer"&gt;user stories&lt;/a&gt;" (as a [role], I want to [do something] so that [outcome]) or "&lt;a href="https://hbr.org/2016/09/know-your-customers-jobs-to-be-done" rel="noopener noreferrer"&gt;jobs to be done&lt;/a&gt;" (when [situation], I want to [motivation], so I can [expected outcome]).&lt;/p&gt;

&lt;p&gt;This is useful for DevRel because it's how you can frame developer feedback. Instead of "developers want feature X," try "when developers are trying to debug production issues, they want observability into what the system is doing, so they can identify root causes faster."&lt;/p&gt;

&lt;h3&gt;
  
  
  Prioritization Frameworks
&lt;/h3&gt;

&lt;p&gt;PMs are always prioritizing. Common frameworks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.intercom.com/blog/rice-simple-prioritization-for-product-managers/" rel="noopener noreferrer"&gt;RICE&lt;/a&gt;&lt;/strong&gt;: Reach, Impact, Confidence, Effort&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Value vs. Effort&lt;/strong&gt;: Classic 2x2 matrix&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.productplan.com/glossary/kano-model/" rel="noopener noreferrer"&gt;Kano Model&lt;/a&gt;&lt;/strong&gt;: Basic needs vs. performance needs vs. delighters&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Understanding how your product team prioritizes helps you frame feedback effectively.&lt;/p&gt;

&lt;h3&gt;
  
  
  Success Metrics
&lt;/h3&gt;

&lt;p&gt;Good PMs are all about metrics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Activation&lt;/strong&gt;: Did users do the key action that shows they "get" the product?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Engagement&lt;/strong&gt;: Are users coming back? How often?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention&lt;/strong&gt;: Are users sticking around over time?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature adoption&lt;/strong&gt;: Are users actually using the new thing we built?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to value&lt;/strong&gt;: How quickly can users get value from the product?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice how some of these overlap with PLG metrics? That's not an accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Product Matters So Much to DevRel
&lt;/h2&gt;

&lt;p&gt;Here's where it gets interesting. Product is uniquely positioned to give you intelligence about the business that you can't get anywhere else.&lt;/p&gt;

&lt;h3&gt;
  
  
  They See Across Functions
&lt;/h3&gt;

&lt;p&gt;A strategic PM talks to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sales (what's blocking deals?)&lt;/li&gt;
&lt;li&gt;Marketing (what's resonating in the market?)&lt;/li&gt;
&lt;li&gt;Customer success (what are customers struggling with?)&lt;/li&gt;
&lt;li&gt;Engineering (what's technically feasible?)&lt;/li&gt;
&lt;li&gt;Finance (what can we afford?)&lt;/li&gt;
&lt;li&gt;Executives (what's the strategy?)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They're synthesizing information from everywhere. If you have a good relationship with product leadership, they can give you context on what's really happening across the business that you'd never get from any single function.&lt;/p&gt;

&lt;h3&gt;
  
  
  They Know What's Coming
&lt;/h3&gt;

&lt;p&gt;Product knows what's on the roadmap before it's public. This is incredibly valuable for DevRel:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You can prep content in advance&lt;/li&gt;
&lt;li&gt;You can understand strategic direction&lt;/li&gt;
&lt;li&gt;You can give early feedback on how developers will react&lt;/li&gt;
&lt;li&gt;You can plan your activities around major releases&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  They Need What You Have
&lt;/h3&gt;

&lt;p&gt;As a devrel, you're sitting on a goldmine of information that product desperately needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What developers are actually struggling with&lt;/li&gt;
&lt;li&gt;What features developers are asking for (and why)&lt;/li&gt;
&lt;li&gt;How developers talk about problems in their own language&lt;/li&gt;
&lt;li&gt;What the competition is doing (you see it at conferences and in communities)&lt;/li&gt;
&lt;li&gt;Which parts of your product confuse people&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This creates natural reciprocity. You help them, they help you.&lt;/p&gt;

&lt;h3&gt;
  
  
  They Fight Similar Battles
&lt;/h3&gt;

&lt;p&gt;Good PMs face the same challenge you do: they have to translate between technical reality and business objectives. They have to advocate for what's right while acknowledging business constraints.&lt;/p&gt;

&lt;p&gt;You're natural allies.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Product/DevRel Partnership
&lt;/h2&gt;

&lt;p&gt;When done right, the Product/DevRel partnership is incredibly powerful:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DevRel provides Product:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Raw, unfiltered user feedback from communities&lt;/li&gt;
&lt;li&gt;Market intelligence from conferences and events&lt;/li&gt;
&lt;li&gt;Early signals about what developers care about&lt;/li&gt;
&lt;li&gt;Reality checks on how features will be received&lt;/li&gt;
&lt;li&gt;Developer perspective on product decisions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Product provides DevRel:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Early access to roadmap and upcoming features&lt;/li&gt;
&lt;li&gt;Context on why decisions were made&lt;/li&gt;
&lt;li&gt;Understanding of strategic priorities&lt;/li&gt;
&lt;li&gt;Cross-functional intelligence&lt;/li&gt;
&lt;li&gt;Air cover when you need to push back on requests&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Feedback Loop
&lt;/h3&gt;

&lt;p&gt;One of DevRel's most valuable contributions is being a structured feedback channel from developers to product. But this only works if you do it right:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bad feedback:&lt;/strong&gt; "Developers want dark mode"&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Good feedback:&lt;/strong&gt; "I'm seeing consistent requests for dark mode from developers who code at night. The specific pain point is eye strain during long coding sessions. This came up in 15+ community conversations last month, and competitors X and Y both shipped this recently."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bad feedback:&lt;/strong&gt; "This new feature is confusing"&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Good feedback:&lt;/strong&gt; "In office hours, 3 out of 5 developers got stuck at the same step in the new onboarding flow. The confusion is around [specific thing]. Here's a recording of where they got stuck."&lt;/p&gt;

&lt;p&gt;Strategic product leaders eat this stuff up because it's specific, actionable, and helps them make better decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not All PMs Are Created Equal
&lt;/h2&gt;

&lt;p&gt;That said, not every PM is strategic. Some are just feature factories, cranking out roadmap items without understanding the broader context.&lt;/p&gt;

&lt;p&gt;A comment that inspired this post mentioned this perfectly: not every product leader can "get their heads out of the sands of features/roadmap, nor are they all observant of business value themselves."&lt;/p&gt;

&lt;p&gt;Look for PMs who:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Understand the business model, not just the feature list&lt;/li&gt;
&lt;li&gt;Can articulate why something is being built, not just what&lt;/li&gt;
&lt;li&gt;Talk to customers and users regularly&lt;/li&gt;
&lt;li&gt;Have opinions about strategy, not just execution&lt;/li&gt;
&lt;li&gt;Are curious about the market and competition&lt;/li&gt;
&lt;li&gt;Actually understand the technical implications of their decisions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are the ones worth partnering closely with. The others... well, you can still work with them, but don't expect the strategic partnership.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Product Decisions Happen (And Where You Fit In)
&lt;/h2&gt;

&lt;p&gt;Understanding how product decisions get made helps you influence them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Input gathering:&lt;/strong&gt; PMs collect input from sales, customers, community, market research, executives, etc.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prioritization:&lt;/strong&gt; PMs use frameworks to decide what's most important&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Roadmap planning:&lt;/strong&gt; Decisions get formalized into "we're building X in Q2"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Execution:&lt;/strong&gt; Engineering builds it&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Launch:&lt;/strong&gt; Product goes to market with help from marketing, sales, and (hopefully) DevRel&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measurement:&lt;/strong&gt; Did it work?&lt;/p&gt;

&lt;p&gt;DevRel can influence at almost every stage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Input:&lt;/strong&gt; Share developer feedback&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prioritization:&lt;/strong&gt; Provide market context&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roadmap:&lt;/strong&gt; React to plans, suggest adjustments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execution:&lt;/strong&gt; Give technical feedback during development&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Launch:&lt;/strong&gt; Create content, drive awareness&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measurement:&lt;/strong&gt; Share qualitative feedback on how it's landing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But you have to be in the conversation to have influence. Which means building relationships.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DevRel Seat at the Product Table
&lt;/h2&gt;

&lt;p&gt;Some DevRel teams are deeply integrated with product. Others are completely disconnected. If you're in the latter camp, here's how to change that:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start small:&lt;/strong&gt; Ask to sit in on a product planning meeting as an observer&lt;br&gt;
&lt;strong&gt;Provide value first:&lt;/strong&gt; Share useful feedback before asking for anything&lt;br&gt;
&lt;strong&gt;Speak their language:&lt;/strong&gt; Frame things in terms of user problems and business impact&lt;br&gt;
&lt;strong&gt;Be consistent:&lt;/strong&gt; Show up regularly with valuable input&lt;br&gt;
&lt;strong&gt;Don't be precious:&lt;/strong&gt; Accept that you won't always get what you want&lt;br&gt;
&lt;strong&gt;Understand constraints:&lt;/strong&gt; Product is making trade-offs; understand what they are&lt;/p&gt;

&lt;h3&gt;
  
  
  Regular Touchpoints That Work
&lt;/h3&gt;

&lt;p&gt;Consider establishing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Monthly sync with product leadership:&lt;/strong&gt; Share what you're hearing, get context on roadmap&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feedback review sessions:&lt;/strong&gt; Present synthesized developer feedback quarterly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Early access to betas:&lt;/strong&gt; Get hands-on with features before launch&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Launch planning collaboration:&lt;/strong&gt; Work together on go-to-market for major features&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roadmap input sessions:&lt;/strong&gt; Provide perspective on upcoming priorities&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Product Gives You Business Intelligence
&lt;/h2&gt;

&lt;p&gt;Here's something nobody talks about: a good product partner can be your best source of business intelligence.&lt;/p&gt;

&lt;p&gt;They'll tell you things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Sales is really struggling in the enterprise segment right now"&lt;/li&gt;
&lt;li&gt;"We're shifting strategy toward [market] next year"&lt;/li&gt;
&lt;li&gt;"The exec team is worried about [competitive threat]"&lt;/li&gt;
&lt;li&gt;"Marketing's campaigns aren't converting well"&lt;/li&gt;
&lt;li&gt;"There might be budget cuts coming"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why do they know this? Because they're in all the meetings. They see the cross-functional dynamics. They hear what's really going on.&lt;/p&gt;

&lt;p&gt;And if they trust you, they'll share it. This is incredibly valuable for planning your DevRel strategy and seeing what's coming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Essential Product Terminology
&lt;/h2&gt;

&lt;p&gt;Let me give you the vocabulary:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.agilealliance.org/glossary/mvp/" rel="noopener noreferrer"&gt;MVP (Minimum Viable Product)&lt;/a&gt;&lt;/strong&gt;: The smallest version of something that delivers value. Often used as an excuse to ship incomplete things, but the concept is sound.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.lennysnewsletter.com/p/how-to-know-if-youve-got-productmarket" rel="noopener noreferrer"&gt;Product-Market Fit&lt;/a&gt;&lt;/strong&gt;: The magical moment when you've built something that a market actually wants. Most products never achieve this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Feature parity&lt;/strong&gt;: Having the same features as competitors. Often overvalued by sales, undervalued by product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Technical debt&lt;/strong&gt;: The accumulated cost of quick-and-dirty technical decisions. Always more than you think.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dogfooding&lt;/strong&gt;: Using your own product internally. Good PMs do this religiously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Beta / Alpha&lt;/strong&gt;: Early versions for testing. Alpha is usually internal, beta is usually external but limited.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GA (General Availability)&lt;/strong&gt;: The feature is fully launched and available to everyone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sunset / Deprecation&lt;/strong&gt;: Retiring a feature or product. Always painful, usually necessary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.whatmatters.com/faqs/okr-meaning-definition-example/" rel="noopener noreferrer"&gt;OKRs (Objectives and Key Results)&lt;/a&gt;&lt;/strong&gt;: How many product teams set goals. Objectives are qualitative, key results are measurable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means For Your Work
&lt;/h2&gt;

&lt;p&gt;Understanding product doesn't mean becoming a PM. It means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You can provide product with valuable, actionable feedback&lt;/li&gt;
&lt;li&gt;You can anticipate what's coming and plan accordingly&lt;/li&gt;
&lt;li&gt;You can understand why certain decisions are made (even when you disagree)&lt;/li&gt;
&lt;li&gt;You can be a strategic partner instead of just a feedback conduit&lt;/li&gt;
&lt;li&gt;You have access to cross-functional intelligence that helps you navigate the business&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Product is your window into how the whole business is working - the headwinds, the tailwinds, the tensions, the priorities. Use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding Your Product Partner
&lt;/h2&gt;

&lt;p&gt;Not sure who to connect with in product? Start here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identify the PM who owns your developer-facing products&lt;/strong&gt; (API, SDK, CLI, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach out with value:&lt;/strong&gt; "Hey, I'm hearing a lot of feedback about [thing]. Want to chat?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be low-maintenance:&lt;/strong&gt; Respect their time, be organized, come prepared&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look for strategic thinkers:&lt;/strong&gt; Find the PMs who see the bigger picture&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the relationship over time:&lt;/strong&gt; This is a long game&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And if you find a great product partner? Hold onto them. They're rare and incredibly valuable.&lt;/p&gt;

&lt;p&gt;Next up: we'll talk about Product-Led Growth and how it changes everything about the relationship between product, DevRel, and go-to-market. Spoiler: in PLG companies, product and DevRel become deeply intertwined.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Questions about working with product? War stories about great (or terrible) product partnerships? Let's hear them in the comments!&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Previously:&lt;/strong&gt; &lt;a href="https://dev.to/mattstratton/finance-101-budgets-pls-and-the-language-of-money-3dp9"&gt;Part 4: Finance 101&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt; &lt;a href="https://dev.to/mattstratton/product-led-growth-and-what-it-means-for-devrel-10mj"&gt;Product-Led Growth&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devrel</category>
    </item>
  </channel>
</rss>
