[{"body":"Headings\nH1 · Heading one\nH2 · Heading two\nH3 · Heading three\nH4 · Heading four\nH5 · Heading five\nH6 · Heading six\nInline formatting\nText can be bold, italic, bold italic, struck through, or inserted. You can highlight a phrase to draw the eye, drop in inline code, and link to another post.\nScientific bits read cleanly too: H2O, the area πr2, the HTML spec, and keyboard shortcuts like ⌘ K to open search or Esc to close it.\nLists\nUnordered, with nesting A first idea worth its own line A second idea with a supporting detail and one more beneath it A third to close the thought Ordered, with nesting Measure the real workload Form a hypothesis write down the expected win write down how you\u0026#39;ll know Change one thing, measure again Task list Draft the post Add the benchmark numbers Get someone to poke holes in it Definition list Amortized cost The average cost per operation across a worst-case sequence, not the worst single operation. Tail latency The slow end of the distribution — the p99 your users actually notice. Quotes\nA blockquote for the borrowed thought — set apart, a touch quieter than the body.\nAnd a nested quote inside it, for the reply to the borrowed thought.\nCallouts\nNote Hugo renders GitHub-style alerts natively. Label text stays high-contrast; the type is signalled by the bar and icon color, not by tinting the words.\nTip A muted green marks advice — the small semantic hue lives on the icon and bar only.\nWarning Warm amber for the thing that will bite you. Still low-saturation, still at home on the paper.\nTables\nRuntime by approach and input size. Approachn = 1kn = 1MNotes Linear scan0.4 ms380 msCache-friendly, tight stride Sort + sweep1.1 ms240 msWins only past the crossover Hash index0.9 ms190 msBest asymptotically, worst constants Code \u0026amp; syntax\nInline go test -bench=. sits in the run of text. Fenced blocks get syntax colors and optional line highlighting — here the highlighted line is the one that allocates:\nbench_test.go func BenchmarkFib(b *testing.B) { for i := 0; i \u0026lt; b.N; i++ { _ = Fibonacci(4000) // allocates a slice }} Terminal\nFor commands you\u0026#39;d actually run, opt a fenced block into a terminal window — a mac-style frame with traffic lights — by adding {term=true}:\nbash brew install hugo \u0026amp;\u0026amp; hugo server Set the language to console to show a command with its output; the prompt and the output are styled apart:\nbash $ hugo --gc Start building","date":"Jul 22, 2026","description":"Everything the theme knows how to render — headings, inline marks, lists, tables, callouts, math, diagrams, footnotes — shown in context so the type and spacing can be judged together.","iso":"2026-07-22","section":"til","tags":[],"title":"The Markdown reference","url":"/til/markdown-reference/"},{"body":"There’s a particular kind of confidence that comes from writing O(n log n) in a design doc. It feels like a proof. And it is one — about a limit you will, in practice, never reach.1 The trouble starts when we treat the asymptote as advice for the machine in front of us.\nLast week I replaced a “slow” quadratic scan with a “fast” sort-then-sweep. The theory was unimpeachable. The result was slower — for every realistic input we actually saw.\nThe measurement # Here’s the inner loop I started with. The two highlighted lines are the base case — the part I assumed didn’t matter, and the part the profiler kept pointing at.\nfib.gofunc Fibonacci(n int) []int { seq := make([]int, n) for i := range seq { if i \u0026lt; 2 { seq[i] = i // base case continue } seq[i] = seq[i-1] + seq[i-2] } return seq }Runtime is not one number, it’s a curve with a shape. A more honest model keeps the constants that O-notation throws away:\nT(n) = c1⁢n⁢log⁡n + c2⁢n + c3 When c1 hides a cache miss and c2 is a tight, predictable stride, the linear term wins for every n\u0026lt;106 you’ll ever feed it.\nA cost model, not a verdict # Premature optimization is the root of all evil — but so is treating a limit theorem as a benchmark.\nSo now I hold two numbers in my head before I reach for a cleverer algorithm:\nThe asymptotic class — does this fall off a cliff as inputs grow? The constant — what does it actually cost at the size I ship? The rule I settled on # Measure first, at the real input size, on the real hardware. Let the asymptote decide only when the two curves actually cross — and then write down where they crossed, because the next person (usually me) will want the number, not the theorem.\nThe whole trick of asymptotic analysis is to let n run to infinity so the low-order terms vanish — useful for theory, a liability when your real n is small and fixed. ↩︎","date":"Jul 20, 2026","description":"Asymptotic complexity is a promise about behavior at infinity. Most of my code never runs there — it runs at n = 4,000 on a Tuesday, and the constants I dropped are the whole story.","iso":"2026-07-20","section":"blog","tags":["performance","go","measurement"],"title":"When Big-O Lies","url":"/blog/when-big-o-lies/"},{"body":"When you just want to read an API response and don’t feel like installing anything:\nbash curl -s https://api.example.com/thing | python -m json.tool python -m json.tool ships with every Python install and pretty-prints stdin. No jq, no dependencies. Here’s the whole thing, command and output:\nbash $ echo \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;ada\u0026#34;,\u0026#34;langs\u0026#34;:[\u0026#34;go\u0026#34;,\u0026#34;rust\u0026#34;]}\u0026#39; | python -m json.tool { \u0026#34;name\u0026#34;: \u0026#34;ada\u0026#34;, \u0026#34;langs\u0026#34;: [ \u0026#34;go\u0026#34;, \u0026#34;rust\u0026#34; ] } For anything more than eyeballing — filtering, reshaping — reach for jq. For a quick look, this is already on your machine.","date":"Jul 18, 2026","description":"curl -s url | python -m json.tool — no jq required for a quick look.","iso":"2026-07-18","section":"til","tags":["shell","json"],"title":"Pretty-print JSON in one shell pipe","url":"/til/pretty-print-json/"},{"body":"git checkout does too much: it moves branches, restores files, detaches HEAD, and creates branches — all under one overloaded verb. That’s why it’s a classic source of “I ran the right command and lost my changes.”\nSince Git 2.23 the job is split into two honest commands:\ngit switch -c new-feature # create and move to a branch git switch main # move to an existing branch git restore path/to/file # discard changes to a fileswitch only changes branches. restore only touches files. The name tells you the blast radius before you hit enter.","date":"Jul 9, 2026","description":"git switch -c branch says what it does; checkout is four tools in a trenchcoat.","iso":"2026-07-09","section":"til","tags":["git"],"title":"git switch beats git checkout","url":"/til/git-switch-vs-checkout/"},{"body":"When review feedback belongs in a commit three back, you don’t have to hand-edit a rebase todo. Mark the fix, then let Git sort it:\ngit commit --fixup=\u0026lt;sha\u0026gt; # stages a \u0026#34;fixup! \u0026lt;subject\u0026gt;\u0026#34; commit git rebase -i --autosquash main # reorders and squashes it automatically--fixup writes a specially-named commit; --autosquash recognizes the name, moves it next to its target, and pre-marks it as fixup in the todo list. You just confirm. Set git config --global rebase.autosquash true and the flag is on by default.","date":"Jul 2, 2026","description":"Amend an older commit in a branch without an interactive rebase dance.","iso":"2026-07-02","section":"til","tags":["git"],"title":"git commit --fixup and autosquash","url":"/til/git-fixup-autosquash/"},{"body":"The first time I saw a sorted array process faster than an identical unsorted one, I assumed I’d measured wrong. Same data, same instructions, same count. The only difference was order — and order, I’d been taught, doesn’t change complexity.\nIt doesn’t. It changes the constant, and the constant is a physical thing living in the branch predictor.\nThe branch that couldn’t guess # A modern CPU runs ahead of itself. When it hits an if, it doesn’t wait to learn the answer — it bets, and speculatively executes the branch it thinks you’ll take. Guess right and the work is already done. Guess wrong and it throws away a dozen in-flight instructions and starts over.\nOn sorted data the condition if x \u0026gt; threshold is a long run of true, then a long run of false. The predictor learns that in a heartbeat. On shuffled data it’s a coin flip, and the predictor is wrong roughly half the time. Same loop; a mispredict tax on every iteration.\nBoring on purpose # The lesson I keep relearning is that predictable code is fast code — often faster than clever code. Sort the data if you’re going to branch on it. Prefer a branchless min/max in a hot loop. Lay your structs out so the field you touch every iteration sits in the same cache line as the one next to it.\nNone of this shows up in a complexity analysis. All of it shows up in a flame graph.","date":"Jun 30, 2026","description":"What branch prediction taught me about writing boring, predictable code on purpose.","iso":"2026-06-30","section":"blog","tags":["performance","cpu"],"title":"The Quiet Cost of a Fast Loop","url":"/blog/quiet-cost-of-a-fast-loop/"},{"body":"Before Go 1.20, returning “here is everything that’s wrong” meant building your own error slice type. Now the standard library does it:\nfunc validate(u User) error { var errs error if u.Name == \u0026#34;\u0026#34; { errs = errors.Join(errs, errors.New(\u0026#34;name is required\u0026#34;)) } if u.Age \u0026lt; 0 { errs = errors.Join(errs, errors.New(\u0026#34;age must be non-negative\u0026#34;)) } return errs // nil if nothing was joined }errors.Join ignores nil arguments, so the accumulate-in-a-loop pattern just works. And errors.Is / errors.As still see through the joined result to each underlying error.","date":"Jun 27, 2026","description":"You can wrap multiple errors into one since 1.20 — great for validation that collects everything.","iso":"2026-06-27","section":"til","tags":["go","errors"],"title":"Go's errors.Join","url":"/til/go-errors-join/"},{"body":"Multiple defer statements in a function run in reverse order — the last one deferred runs first:\nfunc process() { f := open() defer f.Close() // runs second tx := f.Begin() defer tx.Rollback() // runs first // ... }This is the right default: teardown happens in the reverse of setup, so inner resources release before the outer ones they depend on. It mirrors how a stack unwinds — which is the whole point.","date":"Jun 18, 2026","description":"Stacked defers unwind last-in-first-out — which is exactly what you want for paired setup and teardown.","iso":"2026-06-18","section":"til","tags":["go"],"title":"Go's defer runs in LIFO order","url":"/til/defer-runs-lifo/"},{"body":"The standard library is the best-reviewed code most of us will ever have free access to, and almost nobody reads it. I’ve started treating it like a museum you’re allowed to touch — pick one function, follow it all the way down, and notice every decision that isn’t the obvious one.\nsort.Slice is not quicksort # If you’d asked me to implement sort.Slice, I’d have written quicksort and moved on. Go’s implementation is a pattern-defeating quicksort — pdqsort — and the difference is a catalogue of the ways plain quicksort betrays you in production.\nIt detects already-sorted and reverse-sorted runs and bails to a linear pass. It switches to insertion sort under a small threshold, where the constants favor simplicity. It falls back to heapsort when recursion goes too deep, killing quicksort’s O(n²) worst case dead. Every one of those branches exists because someone hit the pathological input in the wild.\nWhy bother # Reading shipped code rewires your taste. You stop asking “what’s the algorithm?” and start asking “what inputs did they refuse to be surprised by?” That question has made me a better engineer than any algorithms course did.","date":"Jun 11, 2026","description":"A slow walk through sort.Slice, and why the obvious implementation isn't the one they shipped.","iso":"2026-06-11","section":"blog","tags":["go","reading-code"],"title":"Reading the Standard Library for Fun","url":"/blog/reading-the-standard-library/"},{"body":"The joke is that there are two hard problems in computer science: cache invalidation and naming things. I’ve come to think they’re the same problem wearing two hats.\nWhat a key really is # A cache key is a name for a claim: “the answer to this question is that value.” Invalidation is just noticing the claim stopped being true. The reason invalidation is hard is almost never the eviction mechanics — it’s that the key doesn’t actually name the claim.\nWhen a key is user:42, what does it promise? The user’s profile? Their permissions? Their last-seen timestamp? If three different code paths write to that key meaning three different things, no invalidation strategy will save you. You didn’t have a caching bug. You had a naming bug that cached.\nThe fix is boring # Name the claim precisely and the invalidation falls out:\nuser:42:profile:v3 invalidates when the profile changes, and the v3 lets you roll the whole namespace when the shape changes. Derived data keys its version off its inputs, so a stale input can’t produce a fresh-looking output. If you can state, in one sentence, exactly what a key promises and what events break that promise, invalidation stops being scary. If you can’t state it, no TTL will hide that you don’t know what you cached.","date":"May 12, 2026","description":"The two hard problems collapse into one: if you can't name what a cache entry represents, you can't know when it's stale.","iso":"2026-05-12","section":"blog","tags":["systems","caching"],"title":"Cache Invalidation, Mostly Naming","url":"/blog/cache-invalidation-mostly-naming/"},{"body":"I keep a mental list of times I was certain I knew where the time was going, ran a profiler to confirm it, and got humbled. The list is long. The profiler’s record against me is perfect.\nThe bottleneck was never where I looked # I’d stare at the gnarly nested loop, the obvious O(n²), and optimize it for an afternoon. The profiler would then point, flatly, at a json.Marshal call in the logging path that ran on every request. The scary algorithm ran twice a day. The boring serialization ran a million times an hour.\nIntuition is trained on what looks expensive. Profilers measure what is expensive. Those two agree far less often than my ego would like.\nHow I argue with it now # I don’t. I’ve adopted a rule: no performance change ships without a before-and-after from the same tool, on the same input. Not because I don’t trust myself — because I’ve watched myself be wrong too many times to pretend otherwise.\nThe corollary is uncomfortable but freeing: most of my code is fast enough, and the profiler will tell me exactly which small part isn’t. I just have to stop guessing and go look.","date":"Apr 3, 2026","description":"Every time I've argued with a profiler, I've been wrong. A short catalogue of my defeats.","iso":"2026-04-03","section":"blog","tags":["performance","debugging"],"title":"The Profiler Is Always Right","url":"/blog/the-profiler-is-always-right/"},{"body":"A snapshot of what I reach for. It changes slowly — I tend to keep tools until they actively get in my way.\nEditor \u0026amp; terminal # Neovim with a config I maintain as kolan.vim. LSP, Telescope for fuzzy everything, Treesitter. Ghostty as the terminal. Fast, GPU-accelerated, sensible defaults. tmux for sessions that outlive the terminal window. fish shell for interactive use, POSIX sh for anything I’d commit. Languages \u0026amp; build # Go for services and CLIs — fast builds, boring concurrency, one binary out. Rust when I need the guarantees or the last 20% of performance. SQLite for local tools, Postgres for anything that outlives a process. Hardware # 14\u0026#34; MacBook Pro (M-series) — the fans have never turned on. A mechanical keyboard with tactile switches, because I stare at it all day. One external monitor. Two felt like more context-switching, not less. Also on the machine # ripgrep and fd — I haven’t typed grep or find in years. jq for JSON, hyperfine for benchmarks, git for everything. ","date":"Feb 1, 2026","description":"The software and hardware that make up my daily workflow.","iso":"2026-02-01","section":"","tags":[],"title":"Tools I Use","url":"/tools-i-use/"},{"body":"No rating system and no particular order. If a book is here, it earned the time and changed how I think about something.\nRecently # The Art of Doing Science and Engineering — Richard Hamming. Half technique, half career advice from someone who watched computing get invented. The “you and your research” chapter is worth the whole book. A Philosophy of Software Design — John Ousterhout. The clearest argument I’ve read that complexity is the enemy and deep modules are the cure. Short, opinionated, correct. Designing Data-Intensive Applications — Martin Kleppmann. The book I wish I’d had before I ever touched a distributed system. I re-read a chapter roughly every time I break production. Older, still with me # The Mythical Man-Month — Fred Brooks. Fifty years old and still describes every project I’ve been on. Working in Public — Nadia Eghbal. Changed how I think about open source maintenance and the cost of “free.” ","date":"Jan 15, 2026","description":"Books that stuck with me, and a line on why.","iso":"2026-01-15","section":"","tags":[],"title":"Reading List","url":"/reading-list/"},{"body":"I spent last year reading one public postmortem a week — outages from companies large enough to be forced into candor. I expected exotic failures. What I found was the same three or four stories wearing different logos.\nThe retry that became the outage # The single most common shape: a small failure triggers automatic retries, the retries multiply the load, and the load turns a blip into a self-sustaining fire. The system’s own recovery mechanism is the accelerant.\nThe fix is almost always the same words — exponential backoff, jitter, a circuit breaker — and it’s almost never in place before the incident that teaches it.\nNobody was watching the thing that broke # The second story: the failure was in a dependency two hops away from any dashboard. Metrics existed for the service and for the database, but not for the queue between them, and that queue is exactly where the pressure built.\nYou monitor what you’ve already been burned by. The next outage is, by definition, in the place you haven’t.\nThe postmortem is the product # The best writeups don’t just explain what happened. They change a default, add an alert, or delete a foot-gun — and they say so, specifically. A postmortem that ends in “we’ll be more careful” hasn’t ended. It’s just paused until next time.","date":"Dec 2, 2025","description":"Patterns in how large systems actually fail, drawn from a year of public incident writeups.","iso":"2025-12-02","section":"blog","tags":["reliability","systems"],"title":"A Year of Reading Postmortems","url":"/blog/a-year-of-reading-postmortems/"},{"body":"My default database is Postgres, and my second choice is also Postgres. This isn’t nostalgia. It’s that “boring” is a technical property, and it’s one I want more of as a system gets important.\nWhat boring buys you # A boring database is one whose failure modes are already written down — in blog posts, in Stack Overflow answers, in the scar tissue of every engineer you’ll ever hire. When it’s 3am and something is wrong, you are not the first person to see this error. That is worth more than almost any feature.\nPostgres also quietly absorbs the jobs people reach for other tools to do:\nJSON columns handle the document-store itch until you genuinely outgrow it. LISTEN/NOTIFY covers a surprising amount of “we need a queue.” Full-text search is good enough to postpone standing up a search cluster for a long time. Every one of those is one fewer moving part to operate at 3am.\nWhen to reach for something else # There are real reasons to leave, and they’re specific — not vibes:\nA genuine write throughput ceiling you’ve measured, not imagined. An access pattern that fights the relational model on every query. A scale where the operational cost of sharding Postgres exceeds the cost of a system built for distribution. If you can’t name which of these you’re hitting, you’re not hitting one. Stay boring a while longer.","date":"Sep 14, 2025","description":"Why my default is still Postgres, and the specific moments that justify anything else.","iso":"2025-09-14","section":"blog","tags":["databases","postgres"],"title":"The Case for Boring Databases","url":"/blog/the-case-for-boring-databases/"},{"body":"","date":"Jan 1, 0001","description":"Find anything across the blog, TIL notes, and projects.","iso":"0001-01-01","section":"","tags":[],"title":"Search","url":"/search/"}]