<?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: Andrey Kolkov</title>
    <description>The latest articles on DEV Community by Andrey Kolkov (@kolkov).</description>
    <link>https://dev.to/kolkov</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%2F277150%2Fdc37d68a-1fc4-4584-a7a6-0e640febd7a8.jpeg</url>
      <title>DEV Community: Andrey Kolkov</title>
      <link>https://dev.to/kolkov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kolkov"/>
    <language>en</language>
    <item>
      <title>Read and Write MATLAB .mat Files in Go Without CGO: Files That Read as Empty, and MATLAB Files We Couldn't Open</title>
      <dc:creator>Andrey Kolkov</dc:creator>
      <pubDate>Sat, 07 Nov 2026 11:57:29 +0000</pubDate>
      <link>https://dev.to/scigolib/read-and-write-matlab-mat-files-in-go-without-cgo-files-that-read-as-empty-and-matlab-files-we-3dg3</link>
      <guid>https://dev.to/scigolib/read-and-write-matlab-mat-files-in-go-without-cgo-files-that-read-as-empty-and-matlab-files-we-3dg3</guid>
      <description>&lt;p&gt;The previous article was about &lt;a href="https://dev.to/scigolib/hdf5-in-pure-go-no-cgo-a-5-kb-file-that-allocated-40-gb-and-round-trip-tests-that-lied-1jao"&gt;scigolib/hdf5&lt;/a&gt;, a pure Go HDF5 library born out of the need to read MATLAB data in Go. This is the other half of that story: &lt;a href="https://github.com/scigolib/matlab" rel="noopener noreferrer"&gt;scigolib/matlab&lt;/a&gt;, the library that actually reads the &lt;code&gt;.mat&lt;/code&gt; files.&lt;/p&gt;

&lt;p&gt;It reads and writes both MATLAB formats in pure Go, with no cgo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;v5&lt;/strong&gt; (MATLAB v5 to v7.2): the classic binary format, including the zlib-compressed elements MATLAB writes by default;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v7.3&lt;/strong&gt;: HDF5 underneath, through scigolib/hdf5.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What works today: every numeric class, complex numbers, multi-dimensional arrays, reading char arrays, both byte orders, 32-bit targets in CI, and a fuzz-tested v5 parser. Compressed elements are capped at 100 MB each and a 1000:1 ratio, so a zip bomb fails instead of eating memory.&lt;/p&gt;

&lt;p&gt;What doesn't yet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;structures and cell arrays are not decoded (in v5 you get the class; a v7.3 struct comes back as one variable per field);&lt;/li&gt;
&lt;li&gt;sparse matrices;&lt;/li&gt;
&lt;li&gt;complex numbers in v7.3 use a real/imag group, not MATLAB's compound layout;&lt;/li&gt;
&lt;li&gt;character arrays can be read but not written;&lt;/li&gt;
&lt;li&gt;the writer doesn't compress;&lt;/li&gt;
&lt;li&gt;an empty v5 cell &lt;code&gt;{}&lt;/code&gt; makes &lt;code&gt;Open&lt;/code&gt; fail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of what I want to tell here are bugs, and nearly all of them share one cause:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Our tests mostly saw files we wrote ourselves.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Files saved with MATLAB's defaults read as empty
&lt;/h2&gt;

&lt;p&gt;By default, MATLAB's &lt;code&gt;save&lt;/code&gt; writes a Version 7 MAT file with compression on: each variable is wrapped in a &lt;code&gt;miCOMPRESSED&lt;/code&gt; element. Up to v0.3.0, the reader skipped elements it didn't handle. So for a file saved with plain &lt;code&gt;save data.mat&lt;/code&gt;, &lt;code&gt;Open&lt;/code&gt; returned no error and zero variables.&lt;/p&gt;

&lt;p&gt;Reproduced on the v0.3.0 tag, with SciPy's test file &lt;code&gt;inner_outer_tbl_param.mat&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v0.3.0:   0 variables, err = nil
v0.3.19: 12 variables
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our tests never caught it. They read only our own writer's output, which isn't compressed. Three compressed SciPy files were already sitting in &lt;code&gt;testdata&lt;/code&gt;, but no test opened them until v0.3.15.&lt;/p&gt;

&lt;p&gt;v0.3.1 added zlib decompression with the bomb limits above. The lesson was the same one as hdf5's 116 datasets that read "fine": an empty result without an error is worse than a crash.&lt;/p&gt;




&lt;h2&gt;
  
  
  A bug report that waited three months
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/scigolib/matlab/issues/21" rel="noopener noreferrer"&gt;Issue #21&lt;/a&gt; from &lt;a href="https://github.com/EtherHelm" rel="noopener noreferrer"&gt;@EtherHelm&lt;/a&gt; was the first and so far only external issue the project has had. MATLAB couldn't read files the library wrote. They had already traced the v5 cause themselves.&lt;/p&gt;

&lt;p&gt;The array flags element has two 32-bit words. The spec puts the class in bits 0–7 of the first word and the flags in bits 8–11; the second word is &lt;code&gt;nzmax&lt;/code&gt;. Our &lt;code&gt;encodeArrayFlags&lt;/code&gt; put them in the wrong words. And the reader had the mirror bug: it read the class from the second word.&lt;/p&gt;

&lt;p&gt;So the writer and the reader agreed with each other, and every round-trip test passed. Same story as the compound datatypes in hdf5: a round trip through your own code proves only that your code is consistent with itself.&lt;/p&gt;

&lt;p&gt;What that looked like from the outside, reproduced on the v0.3.14 tag: real files saved by MATLAB and SciPy read with the correct values, but every variable's &lt;code&gt;DataType&lt;/code&gt; came back as &lt;code&gt;unknown&lt;/code&gt;, because the class was taken from &lt;code&gt;nzmax&lt;/code&gt;, which is 0. A second bug sat next to it: the byte-order mapping was inverted, so the default output was big-endian.&lt;/p&gt;

&lt;p&gt;The issue was opened on June 7 and answered and fixed on September 2. The plain reason: most of my time goes to &lt;a href="https://github.com/gogpu" rel="noopener noreferrer"&gt;GoGPU&lt;/a&gt;, &lt;a href="https://github.com/born-ml/born" rel="noopener noreferrer"&gt;Born&lt;/a&gt; and &lt;a href="https://github.com/coregx/coregex" rel="noopener noreferrer"&gt;coregex&lt;/a&gt;, and a notification about a new issue here can get lost. Once those reach their releases, there will be more time to go through every library regularly. Until then, a report like this one, with the cause already traced, is the fastest way to a fix.&lt;/p&gt;

&lt;p&gt;The fix (&lt;a href="https://github.com/scigolib/matlab/pull/23" rel="noopener noreferrer"&gt;PR #23&lt;/a&gt;) put the spec layout on both sides and added two kinds of tests nobody can satisfy by agreeing with themselves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reading SciPy's reference &lt;code&gt;.mat&lt;/code&gt; files;&lt;/li&gt;
&lt;li&gt;golden-byte tests that compare our output with the spec, byte by byte.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more honest note. The v0.1.1-beta CHANGELOG said "Files now fully compatible with MATLAB/Octave". No test in the repository has ever run MATLAB or Octave. @EtherHelm's report was the first real check, and it failed.&lt;/p&gt;




&lt;h2&gt;
  
  
  A 136-byte file that could ask for 2 GiB
&lt;/h2&gt;

&lt;p&gt;The same class of bug as hdf5's &lt;a href="https://github.com/scigolib/hdf5/security/advisories/GHSA-8p88-h28q-w6m9" rel="noopener noreferrer"&gt;GHSA-8p88-h28q-w6m9&lt;/a&gt;: the v5 parser allocated each element at the size its tag claimed, before reading a byte of it, up to a 2 GiB cap. A 136-byte input that claimed 64 MiB allocated 64 MiB, then failed.&lt;/p&gt;

&lt;p&gt;The rest turned up while bumping the hdf5 dependency:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an untyped &lt;code&gt;2^31&lt;/code&gt; constant used as an &lt;code&gt;int&lt;/code&gt; broke the build on linux/386, linux/arm and windows/386;&lt;/li&gt;
&lt;li&gt;a claimed size of exactly 2 GiB slipped past the cap and panicked inside &lt;code&gt;make&lt;/code&gt; on 386.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;v0.3.16 validates every size against the bytes actually present, in one place, the tag reader. When the input length is unknown, it accumulates instead of allocating up front. A CI job now builds for linux/386, linux/arm and windows/386 and runs the tests on linux/386.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix that broke &lt;code&gt;bytes.Buffer&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The release review of v0.3.17 found that the v0.3.16 fix had broken something else.&lt;/p&gt;

&lt;p&gt;The bound measured how much input was left after reading the 128-byte header. A &lt;code&gt;bytes.Buffer&lt;/code&gt; reports only its unread part, so the bound came out 128 bytes short, and every valid v5 file opened from a &lt;code&gt;bytes.Buffer&lt;/code&gt; was rejected: &lt;code&gt;tag size beyond end of file&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;No test had passed a &lt;code&gt;bytes.Buffer&lt;/code&gt; to &lt;code&gt;Open&lt;/code&gt;. Now one test opens every test file through &lt;code&gt;os.File&lt;/code&gt;, &lt;code&gt;bytes.Reader&lt;/code&gt;, &lt;code&gt;bytes.Buffer&lt;/code&gt; and a reader that hides its size.&lt;/p&gt;

&lt;p&gt;The same release also caught a stream cut off inside a skipped element being accepted, because &lt;code&gt;io.CopyN&lt;/code&gt; errors were dropped. The review added that a file cut short gave a different error depending on whether its length was known. Every path now returns &lt;code&gt;io.ErrUnexpectedEOF&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  MATLAB's own v7.3 files didn't open
&lt;/h2&gt;

&lt;p&gt;This bug came to light during the fact-checking for this article, and it affected every v7.3 file MATLAB writes.&lt;/p&gt;

&lt;p&gt;MATLAB writes a v7.3 file as HDF5 behind a 512-byte user block. The first 128 bytes of that block are the usual MAT header, with version &lt;code&gt;0x0200&lt;/code&gt;; the HDF5 signature starts at byte 512. Our &lt;code&gt;Open&lt;/code&gt; looked for HDF5 only at offset 0, didn't find it, and handed the file to the v5 parser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tag size beyond end of file: 169478669 bytes at offset 512
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All our v7.3 test files came from our own writer, which writes no user block. Same blind spot, third time.&lt;/p&gt;

&lt;p&gt;There was no MATLAB-written v7.3 file at hand, so the layout was built from the references: &lt;a href="https://github.com/tbeu/matio" rel="noopener noreferrer"&gt;matio&lt;/a&gt; (&lt;code&gt;Mat_Open&lt;/code&gt; reads the version at bytes 124–125 and opens &lt;code&gt;0x0200&lt;/code&gt; through HDF5; &lt;code&gt;Mat_Create73&lt;/code&gt; sets a 512-byte user block), SciPy's &lt;code&gt;_get_matfile_version&lt;/code&gt;, and the HDF5 C library's signature search, which looks at offset 0 and then at every power of two from 512. &lt;code&gt;h5jam -u&lt;/code&gt; produced a test file byte-identical to "block + file", and &lt;code&gt;h5dump&lt;/code&gt; opens it. v0.3.18 detects the format by the header version, as matio and SciPy do.&lt;/p&gt;

&lt;p&gt;The other direction took one more release. Our own v7.3 output had no MAT header either, and #21 had mentioned v7.3 too, so this may well have been that half of it. Writing the header needed user-block support in hdf5, the equivalent of the C library's &lt;code&gt;H5Pset_userblock&lt;/code&gt;. hdf5 v0.15.0 added it, and v0.3.19 writes the header the way matio's &lt;code&gt;Mat_Create73&lt;/code&gt; does. &lt;code&gt;h5dump&lt;/code&gt; opens the files. Whether MATLAB loads them, nobody has confirmed yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Test data and docs that claimed too much
&lt;/h2&gt;

&lt;p&gt;Two smaller ones, in the same spirit.&lt;/p&gt;

&lt;p&gt;The 8 v7.3 test files were written in November 2025 by an early hdf5 version, and &lt;code&gt;h5dump&lt;/code&gt; can't open them; the hdf5 article tells that story from its side. They are regenerated now, and a test fails on any hdf5 warning. One old file stays in &lt;code&gt;testdata/legacy&lt;/code&gt; to prove such files still read.&lt;/p&gt;

&lt;p&gt;The README listed structures, cell arrays and sparse matrices as supported, and complex numbers as "proper MATLAB format for both v5 and v7.3". The reader decoded none of the three, and the v7.3 half of the complex claim wasn't true. It now says what the code does, with a Known Limitations list.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Your own files are not compatibility tests.&lt;/strong&gt; Compressed files, the flags layout, the user block: each time, the tests saw little beyond what our writer produced. Test against files written by the other implementations from the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A silent empty result is worse than an error.&lt;/strong&gt; Zero variables and &lt;code&gt;nil&lt;/code&gt; looked like success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Measure the input before you consume it&lt;/strong&gt;, and test every kind of reader your API accepts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Don't rely on email alone for issues.&lt;/strong&gt; #21 waited three months because the notification went unseen. With many projects, look at each one's open issues directly from time to time.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;MATLAB's compound layout for complex numbers, read and write;&lt;/li&gt;
&lt;li&gt;structures, cell arrays, sparse matrices, empty cells;&lt;/li&gt;
&lt;li&gt;writing character arrays.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;If you have MATLAB or Octave, I'd like your help.&lt;/strong&gt; Write a v5 and a v7.3 file with v0.3.19, load them there, and tell me what happens in an issue. Nobody has verified our v7.3 output in MATLAB itself, and a short report from a real MATLAB session is worth more than any test written without one.&lt;/p&gt;

&lt;p&gt;Thanks to &lt;a href="https://github.com/EtherHelm" rel="noopener noreferrer"&gt;@EtherHelm&lt;/a&gt; for #21, and for doing half the work in it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;matlab&lt;/strong&gt;: &lt;a href="https://github.com/scigolib/matlab" rel="noopener noreferrer"&gt;github.com/scigolib/matlab&lt;/a&gt; · &lt;a href="https://pkg.go.dev/github.com/scigolib/matlab" rel="noopener noreferrer"&gt;pkg.go.dev&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go get github.com/scigolib/matlab@v0.3.19
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"data.mat"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;mat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;matlab&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// v5 or v7.3, detected from the header&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;mat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Variables&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&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;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Dimensions&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;



</description>
      <category>go</category>
      <category>matlab</category>
      <category>datascience</category>
      <category>opensource</category>
    </item>
    <item>
      <title>HDF5 in Pure Go, No CGO: a 5 KB File That Allocated 40 GB, and Round-Trip Tests That Lied</title>
      <dc:creator>Andrey Kolkov</dc:creator>
      <pubDate>Sat, 07 Nov 2026 02:01:30 +0000</pubDate>
      <link>https://dev.to/scigolib/hdf5-in-pure-go-no-cgo-a-5-kb-file-that-allocated-40-gb-and-round-trip-tests-that-lied-1jao</link>
      <guid>https://dev.to/scigolib/hdf5-in-pure-go-no-cgo-a-5-kb-file-that-allocated-40-gb-and-round-trip-tests-that-lied-1jao</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update, 2026-11-07:&lt;/strong&gt; &lt;a href="https://github.com/scigolib/hdf5/releases/tag/v0.15.0" rel="noopener noreferrer"&gt;v0.15.0&lt;/a&gt; is out, and it is the version to use: &lt;code&gt;go get github.com/scigolib/hdf5@v0.15.0&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade from v0.14.3.&lt;/strong&gt; A group that switched to dense link storage while being written (at its ninth link, for example in a group that also holds an external link) could leave a file the C library rejects as truncated: &lt;code&gt;end-of-file address N is beyond the file size M&lt;/code&gt;. v0.15.0 fixes it. An affected file is repaired by zero-extending it to N (on Linux and macOS: &lt;code&gt;truncate -s N file.h5&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New:&lt;/strong&gt; user blocks (&lt;code&gt;WithUserBlock&lt;/code&gt;, &lt;code&gt;FileWriter.WriteUserBlock&lt;/code&gt;, the equivalent of &lt;code&gt;H5Pset_userblock&lt;/code&gt;). That is where a MATLAB v7.3 file keeps its MAT header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behaviour change:&lt;/strong&gt; &lt;code&gt;FileWriter.Delete&lt;/code&gt; on a non-empty group now deletes the group and everything only it links to, as &lt;code&gt;H5Ldelete&lt;/code&gt; does. Code that relied on the old refusal should check that the group is empty first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The follow-up article, about the &lt;code&gt;.mat&lt;/code&gt; reader and writer built on this library: &lt;a href="https://dev.to/scigolib/read-and-write-matlab-mat-files-in-go-without-cgo-files-that-read-as-empty-and-matlab-files-we-3dg3"&gt;Read and Write MATLAB .mat Files in Go Without CGO&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In June 2025 I was porting &lt;a href="https://github.com/ALD-Lab/SURD" rel="noopener noreferrer"&gt;SURD&lt;/a&gt;, a causal-discovery method from a Nature Communications paper, from its Python reference to Go (&lt;a href="https://github.com/causalgo/causalgo" rel="noopener noreferrer"&gt;causalgo&lt;/a&gt;). The reference data came as MATLAB &lt;code&gt;.mat&lt;/code&gt; files. In Python that's one &lt;code&gt;scipy.io.loadmat&lt;/code&gt; call. In Go I found nothing I could rely on.&lt;/p&gt;

&lt;p&gt;Those files happened to be the older v5 format. But a v7.3 &lt;code&gt;.mat&lt;/code&gt; file, the format MATLAB needs for any variable of 2 GB or more, is an HDF5 file, so a Go reader that only handled v5 would fail on the first large dataset. And HDF5 itself is how a large part of science stores its data: climate models, telescopes, particle physics. In Go there were cgo bindings to the C library and a few pure-Go attempts, abandoned years ago and far from complete.&lt;/p&gt;

&lt;p&gt;If I was going to write these libraries at all, it made sense to make them universal from the start. So four days later I started two repositories instead of one narrow reader: &lt;a href="https://github.com/scigolib/matlab" rel="noopener noreferrer"&gt;scigolib/matlab&lt;/a&gt; and &lt;a href="https://github.com/scigolib/hdf5" rel="noopener noreferrer"&gt;scigolib/hdf5&lt;/a&gt;, a full HDF5 library, read and write, in pure Go with no cgo. It has been public for a year now, with 34 releases, and this is the first time I write about it.&lt;/p&gt;

&lt;p&gt;Only later did I understand why the earlier attempts stopped. HDF5 is a scientific binary format with decades of history and a nuance in every structure: several versions of each header and message, variable-width fields set per file, half a dozen chunk index types, a filter pipeline, shared messages, and edge cases that only the C library's own test files show you.&lt;/p&gt;

&lt;p&gt;What's in it today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;superblock v0, v2 and v3; object headers v1 and v2; compact and dense attributes and links;&lt;/li&gt;
&lt;li&gt;contiguous, chunked and compact layouts; GZIP, shuffle and Fletcher32 (LZF and BZIP2 on read);&lt;/li&gt;
&lt;li&gt;compound, array, enum, variable-length, reference and opaque types;&lt;/li&gt;
&lt;li&gt;chunk indexes of every format version, including the ones HDF5 1.10+ writes (fixed and extensible array, B-tree v2, single chunk, implicit);&lt;/li&gt;
&lt;li&gt;reading tested against the HDF5 C library's own test files: 379 of the 383 valid ones read;&lt;/li&gt;
&lt;li&gt;files written by the library are checked with &lt;code&gt;h5dump&lt;/code&gt; in CI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing goes beyond the C library: when the version 2 B-trees behind dense attributes rebalance after deletions. C merges and redistributes their nodes on every delete (its older version 1 B-trees are never rebalanced). Here you choose: immediately, in batches, in a background goroutine, a strategy picked from the workload, or not at all for append-only files.&lt;/p&gt;

&lt;p&gt;What isn't there yet. Each of these fails with an explicit error, not wrong data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;virtual datasets;&lt;/li&gt;
&lt;li&gt;the N-bit, scale-offset and SZIP filters;&lt;/li&gt;
&lt;li&gt;the shared-message heap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not everything is done, but it's close.&lt;/p&gt;

&lt;p&gt;scigolib/matlab reads and writes &lt;code&gt;.mat&lt;/code&gt; files from v5 to v7.3, the v7.3 ones on top of it.&lt;/p&gt;

&lt;p&gt;Then v0.14.3 happened. It started with one report:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One changed byte in a 5,656-byte file made the library try to allocate 40.9 GB.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  A 5 KB file, a 40 GB allocation
&lt;/h2&gt;

&lt;p&gt;Issue &lt;a href="https://github.com/scigolib/hdf5/issues/75" rel="noopener noreferrer"&gt;#75&lt;/a&gt; from &lt;a href="https://github.com/TuSKan" rel="noopener noreferrer"&gt;@TuSKan&lt;/a&gt;. Take a valid file, change the datatype size in the dataset's metadata, and ask for 12 values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadSlice&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;uint64&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;uint64&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="m"&gt;12&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c"&gt;// v0.14.2: tries to allocate 40,873,492,480 bytes, the process dies&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;v0.14.2 had just shipped a fix for this class of bug, with a security advisory (&lt;a href="https://github.com/scigolib/hdf5/security/advisories/GHSA-8p88-h28q-w6m9" rel="noopener noreferrer"&gt;GHSA-8p88-h28q-w6m9&lt;/a&gt;). Whole-dataset reads were bounded. The hyperslab readers weren't: they parsed the dataset metadata again on their own and skipped the check.&lt;/p&gt;

&lt;p&gt;That is the real bug. The fix for one path didn't reach the other paths, because each path had its own copy of the parsing.&lt;/p&gt;

&lt;p&gt;The C library doesn't work like that. When it opens a dataset, it validates the storage once (&lt;code&gt;H5D__contig_check&lt;/code&gt;, &lt;code&gt;H5D__compact_init&lt;/code&gt;, &lt;code&gt;H5D__chunk_set_sizes&lt;/code&gt;), and everything after works from that. v0.14.3 does the same:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one validated dataset descriptor, built once per dataset;&lt;/li&gt;
&lt;li&gt;every reader takes its sizes from it;&lt;/li&gt;
&lt;li&gt;every result buffer is sized through one helper;&lt;/li&gt;
&lt;li&gt;a guard test fails if any allocation sized from file metadata appears outside that helper.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With that in place, the review found four more paths of the same class: the B-tree walk, filter output, variable-length amplification and the compound result size. All four are closed.&lt;/p&gt;

&lt;p&gt;One case can't be bounded by the file size. A chunked dataset may legally be larger than the file, because unwritten chunks read as the fill value. Those reads get a per-file limit: &lt;code&gt;File.SetReadLimit&lt;/code&gt;, 4 GiB by default.&lt;/p&gt;




&lt;h2&gt;
  
  
  Round-trip tests that lied
&lt;/h2&gt;

&lt;p&gt;Issue &lt;a href="https://github.com/scigolib/hdf5/issues/65" rel="noopener noreferrer"&gt;#65&lt;/a&gt; from &lt;a href="https://github.com/kf-domeng" rel="noopener noreferrer"&gt;@kf-domeng&lt;/a&gt;. A compound dataset written by the library opened fine in the library. h5py and H5Web refused it: &lt;code&gt;invalid number of members: 0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My round-trip tests were green the whole time. The writer and the reader had the same misunderstanding of the compound datatype message, so they agreed with each other. A round trip through your own code proves only that your code is consistent with itself.&lt;/p&gt;

&lt;p&gt;The fix was to stop having two opinions about the format:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one datatype codec for every class, mirroring the C library's encoder and decoder;&lt;/li&gt;
&lt;li&gt;the test: decode and re-encode all 1554 datatype messages in the official test files and compare with C's bytes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They match. The one deliberate difference is 8 old v1 compounds with array members, which get upgraded exactly the way C upgrades them.&lt;/p&gt;

&lt;p&gt;Compounds were not the worst case. Dense attribute storage, which HDF5 uses once an object has many attributes, had never been C-compatible at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the attribute info message was written and read as message type 15, which in C is the shared-message table (the attribute info message is 21);&lt;/li&gt;
&lt;li&gt;the attribute name index was built with link records instead of attribute records, and C checks the record type;&lt;/li&gt;
&lt;li&gt;fractal heap objects were addressed from the end of the block header instead of the block start, so C answered "invalid fractal heap offset" for every object we wrote.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of it passed our tests, because our reader made the same mistakes. Run against the C suite, the reader found 0 of the 9 attributes in &lt;code&gt;tattr4_be.h5&lt;/code&gt;, and returned no error. Old files written that way still read, and their storage is moved to the C layout the first time their attributes change.&lt;/p&gt;

&lt;p&gt;Compound types also got a public API, validated the way &lt;code&gt;H5Tinsert&lt;/code&gt; validates members:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;hdf5&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewCompoundType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;hdf5&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompoundMember&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Offset&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hdf5&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Int32&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"energy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Offset&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hdf5&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Float64&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;dw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fw&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateCompound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/events"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;uint64&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dw&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"energy"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;13.6&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"energy"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;27.2&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;
  
  
  116 datasets that read "fine"
&lt;/h2&gt;

&lt;p&gt;This one I found myself, and it bothered me more than the crash.&lt;/p&gt;

&lt;p&gt;HDF5 has optional filters the library doesn't implement: N-bit, scale-offset, SZIP, blosc and others. The old code skipped an unknown filter and returned the stored bytes as if they were the values. In the C test suite, 116 datasets read without an error and returned garbage.&lt;/p&gt;

&lt;p&gt;A crash you notice. Wrong numbers in a science pipeline you may never notice.&lt;/p&gt;

&lt;p&gt;Now an unsupported filter returns &lt;code&gt;ErrNotSupported&lt;/code&gt;, as the C library's filter pipeline does. Three smaller ones of the same kind were fixed along the way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;unwritten chunks read as the fill value, not as zero;&lt;/li&gt;
&lt;li&gt;unsigned compound members above 2^31 no longer come back negative;&lt;/li&gt;
&lt;li&gt;Fletcher32 checksums are verified on read; they used to be stripped unchecked.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Errors you can act on
&lt;/h2&gt;

&lt;p&gt;All the failures above now come back as errors you can tell apart with &lt;code&gt;errors.Is&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;hdf5&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"data.h5"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetReadLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// reads of data the file itself doesn't bound: at most 1 GiB&lt;/span&gt;

&lt;span class="n"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadSlice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hdf5&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrCorrupt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;      &lt;span class="c"&gt;// the file contradicts itself&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hdf5&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrLimit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="c"&gt;// a read above the limit&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hdf5&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrNotSupported&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="c"&gt;// e.g. an SZIP-compressed dataset&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hdf5&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrNoData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;       &lt;span class="c"&gt;// nothing stored, no fill value&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/skyqvn" rel="noopener noreferrer"&gt;@skyqvn&lt;/a&gt; asked in &lt;a href="https://github.com/scigolib/hdf5/issues/67" rel="noopener noreferrer"&gt;#67&lt;/a&gt; for structured metadata instead of parsing &lt;code&gt;Info()&lt;/code&gt; strings. Datasets now have &lt;code&gt;Shape&lt;/code&gt;, &lt;code&gt;MaxShape&lt;/code&gt;, &lt;code&gt;Datatype&lt;/code&gt; and &lt;code&gt;Layout&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The writer moved closer to C as well. In more places it now writes exactly what the C library writes: the fill value message, filter flags, pipeline v2, dataspace v2 and message order. Tests compare its output with C-written files.&lt;/p&gt;

&lt;p&gt;It also got faster. Against v0.14.2, opening and walking a file takes half the time with a tenth of the memory, reading many small slices of a chunked dataset takes 58–72% less time, and across the benchmarks the geometric mean is 44% less time and 64% less memory. (&lt;code&gt;benchstat&lt;/code&gt;, six runs of each benchmark in &lt;a href="https://github.com/scigolib/hdf5/blob/v0.14.3/release_bench_test.go" rel="noopener noreferrer"&gt;&lt;code&gt;release_bench_test.go&lt;/code&gt;&lt;/a&gt;, run against both versions on the same laptop.) Most of it came from one fix: object headers are now read with one read per chunk instead of two per message.&lt;/p&gt;




&lt;h2&gt;
  
  
  Our own old files
&lt;/h2&gt;

&lt;p&gt;The hardest question in this release came from our own sibling project. scigolib/matlab ran its tests against the new, stricter superblock check, and 8 of its &lt;code&gt;.mat&lt;/code&gt; test files were rejected. They had been written by this library in November 2025.&lt;/p&gt;

&lt;p&gt;The library was right to reject them. Versions up to v0.13.4 wrote a CRC32 checksum where HDF5 requires Jenkins lookup3, and &lt;code&gt;h5dump&lt;/code&gt; can't open those files either. Later versions had their own gaps: v2 object headers with no checksum at all, an end-of-file address not updated on close. Files from those versions are not valid HDF5, even though our own reader always opened them.&lt;/p&gt;

&lt;p&gt;So what do you do with files your own library wrote wrong?&lt;/p&gt;

&lt;p&gt;"Be liberal in what you accept" has a bad record: &lt;a href="https://www.rfc-editor.org/info/rfc9413/" rel="noopener noreferrer"&gt;RFC 9413&lt;/a&gt; describes how silent tolerance entrenches bugs. But a targeted workaround for one known buggy writer is standard practice: Apache Parquet ignores the min/max statistics written by old parquet-mr versions, which it identifies by &lt;code&gt;created_by&lt;/code&gt;, instead of rejecting the files. A checksum on metadata guards integrity, not authenticity, so accepting one exact legacy value costs nothing in security.&lt;/p&gt;

&lt;p&gt;The policy in v0.14.3:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;old files still read, and every deviation is reported through &lt;code&gt;File.Warnings()&lt;/code&gt; with a typed code. A file written by the C library or by the current version produces no warnings;&lt;/li&gt;
&lt;li&gt;everything the library writes must be valid HDF5. What can be repaired in place is repaired; what can't is refused with a clear error;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;go.mod&lt;/code&gt; retracts v0.13.9 and older, the versions that wrote files no HDF5 tool can open, so &lt;code&gt;go get&lt;/code&gt; no longer picks them and &lt;code&gt;go list -m -u&lt;/code&gt; tells users why;&lt;/li&gt;
&lt;li&gt;planned: a repack function to convert old files, and from v1.0 a strict default with one explicit legacy option.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read old files, but never write a new broken one, and tell people so nobody keeps producing them.&lt;/p&gt;




&lt;h2&gt;
  
  
  How I check it now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The C library is the oracle.&lt;/strong&gt; Every format check cites the C source it follows, file and line. Where the format spec defines a value, the spec decides, not the C code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every fix starts with a test that fails on the old code.&lt;/strong&gt; Corruption tests claim megabytes against kilobyte inputs. Crafted-input runs work under a 4 GB memory cap, so a missed bound fails the test instead of the machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two reviews.&lt;/strong&gt; Every change gets an adversarial review. Once per release, a second, independent model reviews the final security diff. I started that after the v0.14.2 advisory turned out to be incomplete. I use AI tools for this work openly. They are fast at finding the fifth path of a bug class once you've found the first, and they never replace the test that proves it.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Validate once, at the boundary.&lt;/strong&gt; Every reader that re-parses the metadata is a reader that can forget a check. One validated descriptor turned "fix it in N places" into "fix it once".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Never trust a round trip alone.&lt;/strong&gt; Reading your own output proves only consistency. Test against the other implementation: h5dump, h5py, the C library's bytes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Unknown means error, not "skip".&lt;/strong&gt; A filter you don't understand is a reason to stop, not to hand back bytes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Your own old files are part of the format.&lt;/strong&gt; Users keep what you wrote. Decide what you will read, what you will warn about and what you will write before the strict check ships.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. A security fix covers a class, not a path.&lt;/strong&gt; After the first path, look for every other path that sizes memory from file metadata. In #75 there were five.&lt;/p&gt;




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

&lt;p&gt;Users drive much of this work: the crash, the h5py failure and the metadata API all started as issues from people I'd never met. Thank you, &lt;a href="https://github.com/TuSKan" rel="noopener noreferrer"&gt;@TuSKan&lt;/a&gt;, &lt;a href="https://github.com/kf-domeng" rel="noopener noreferrer"&gt;@kf-domeng&lt;/a&gt; and &lt;a href="https://github.com/skyqvn" rel="noopener noreferrer"&gt;@skyqvn&lt;/a&gt;, and &lt;a href="https://github.com/rhaist" rel="noopener noreferrer"&gt;@rhaist&lt;/a&gt; and &lt;a href="https://github.com/chiliec" rel="noopener noreferrer"&gt;@chiliec&lt;/a&gt; for merged PRs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/scigolib" rel="noopener noreferrer"&gt;scigolib&lt;/a&gt; is meant to be a home for scientific libraries in Go. The longer plan is Go counterparts of the Python libraries people reach for, visualization included. My own main focus is GPU graphics (&lt;a href="https://github.com/gogpu" rel="noopener noreferrer"&gt;GoGPU&lt;/a&gt;) and ML (&lt;a href="https://github.com/born-ml/born" rel="noopener noreferrer"&gt;Born&lt;/a&gt;). So if scientific Go is your thing, I'd be glad of help: issues, PRs, or bringing your own library into scigolib. A co-maintainer for hdf5 would be welcome.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;hdf5&lt;/strong&gt;: &lt;a href="https://github.com/scigolib/hdf5" rel="noopener noreferrer"&gt;github.com/scigolib/hdf5&lt;/a&gt; · &lt;a href="https://pkg.go.dev/github.com/scigolib/hdf5" rel="noopener noreferrer"&gt;pkg.go.dev&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go get github.com/scigolib/hdf5@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>go</category>
      <category>datascience</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>A Shader Loop That Never Ran: What GPU Readback Found in gogpu/naga, a Go WGSL Compiler</title>
      <dc:creator>Andrey Kolkov</dc:creator>
      <pubDate>Tue, 03 Nov 2026 17:07:23 +0000</pubDate>
      <link>https://dev.to/gogpu/a-shader-loop-that-never-ran-what-gpu-readback-found-in-gogpunaga-a-go-wgsl-compiler-41ja</link>
      <guid>https://dev.to/gogpu/a-shader-loop-that-never-ran-what-gpu-readback-found-in-gogpunaga-a-go-wgsl-compiler-41ja</guid>
      <description>&lt;p&gt;Here's a WGSL loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;for (var j = 0u; j &amp;lt; 4u; j++) {
    result[j] = j + 1u;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Until v0.20.3 in October, &lt;a href="https://github.com/gogpu/naga" rel="noopener noreferrer"&gt;naga&lt;/a&gt;, our shader compiler written in Go, turned it into DXIL that never ran the loop. Microsoft's validator looked at the module and said it was valid. On an Intel and an NVIDIA GPU the loop body didn't execute once.&lt;/p&gt;

&lt;p&gt;The validator wasn't wrong. That's the interesting part.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the validator saw
&lt;/h2&gt;

&lt;p&gt;This is the DXIL that naga v0.20.2 writes for that loop, trimmed to the lines that matter, with my comments on the right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight llvm"&gt;&lt;code&gt;  &lt;span class="nv"&gt;%2&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;alloca&lt;/span&gt; &lt;span class="kt"&gt;i32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;align&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;           &lt;span class="c1"&gt;; var j&lt;/span&gt;
  &lt;span class="k"&gt;br&lt;/span&gt; &lt;span class="kt"&gt;label&lt;/span&gt; &lt;span class="nv"&gt;%3&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="nv"&gt;%5&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;load&lt;/span&gt; &lt;span class="kt"&gt;i32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;i32&lt;/span&gt;&lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;%2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;align&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;    &lt;span class="c1"&gt;; read j for "j &amp;lt; 4"&lt;/span&gt;
  &lt;span class="nv"&gt;%6&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;icmp&lt;/span&gt; &lt;span class="k"&gt;ult&lt;/span&gt; &lt;span class="kt"&gt;i32&lt;/span&gt; &lt;span class="nv"&gt;%5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;
  &lt;span class="k"&gt;br&lt;/span&gt; &lt;span class="kt"&gt;i1&lt;/span&gt; &lt;span class="nv"&gt;%6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;label&lt;/span&gt; &lt;span class="nv"&gt;%11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;label&lt;/span&gt; &lt;span class="nv"&gt;%12&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The variable &lt;code&gt;j&lt;/code&gt; gets memory, and the very first thing the loop does is read it. Nothing ever stored the &lt;code&gt;0&lt;/code&gt;. Reading uninitialized memory is legal in LLVM IR, so every instruction here is well formed, the types match, the control flow is structured. IDxcValidator checks exactly those things and reports &lt;code&gt;VALID (S_OK)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What it doesn't check is whether the module computes what the source says. No static validator does, not DXC's and not spirv-val. They answer "can a driver run this?", and the answer was yes. The driver ran it, &lt;code&gt;j&lt;/code&gt; started as whatever was in that memory, and the loop exited before its first iteration.&lt;/p&gt;

&lt;p&gt;naga v0.20.3 fixed three bugs of this kind in the DXIL backend:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The shader&lt;/th&gt;
&lt;th&gt;What the DXIL did&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;for (var j = 0u; j &amp;lt; 4u; j++)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;never stored the initializer; the loop didn't run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;for (var j = i; j &amp;lt; n; j++)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;an optimization pass deleted the initial store, then noticed the loop also writes &lt;code&gt;j&lt;/code&gt;; the loop started from an uninitialized &lt;code&gt;j&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;v[i] = v[i] + 1u&lt;/code&gt; on a local vector&lt;/td&gt;
&lt;td&gt;a run-time index always picked &lt;code&gt;v.x&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For this article I compiled all three shaders with v0.20.2 again. All three come back &lt;code&gt;VALID (S_OK)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Our own tests missed them too. We compare naga's text output with Rust naga's and its DXIL with DXC's, and none of that asks what the shader computes. What found them was a one-off experiment: run the shaders on real GPUs through Direct3D 12 and read the buffers back.&lt;/p&gt;




&lt;h2&gt;
  
  
  What naga is
&lt;/h2&gt;

&lt;p&gt;naga reads WGSL, the WebGPU shading language, and writes SPIR-V for Vulkan, MSL for Metal, GLSL for OpenGL, HLSL for Direct3D, and DXIL for Direct3D 12. The DXIL is emitted directly, without DXC or LLVM. It's plain Go with no CGO, so it cross-compiles like any other Go package.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"log"&lt;/span&gt;

    &lt;span class="s"&gt;"github.com/gogpu/naga/dxil"&lt;/span&gt;
    &lt;span class="s"&gt;"github.com/gogpu/naga/hlsl"&lt;/span&gt;
    &lt;span class="s"&gt;"github.com/gogpu/naga/msl"&lt;/span&gt;
    &lt;span class="s"&gt;"github.com/gogpu/naga/spirv"&lt;/span&gt;
    &lt;span class="s"&gt;"github.com/gogpu/naga/valid"&lt;/span&gt;
    &lt;span class="s"&gt;"github.com/gogpu/naga/wgsl"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;`
@group(0) @binding(0) var&amp;lt;storage, read_write&amp;gt; result: array&amp;lt;u32&amp;gt;;

@compute @workgroup_size(1)
fn main() {
    for (var j = 0u; j &amp;lt; 4u; j++) {
        result[j] = j + 1u;
    }
}
`&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;wgsl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wgsl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParseOptions&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;spv&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;must&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spirv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spirv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;{}))&lt;/span&gt;   &lt;span class="c"&gt;// Vulkan&lt;/span&gt;
    &lt;span class="n"&gt;metal&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;must&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;{}))&lt;/span&gt;     &lt;span class="c"&gt;// Metal&lt;/span&gt;
    &lt;span class="n"&gt;hlslSrc&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;must&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hlsl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hlsl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;{}))&lt;/span&gt; &lt;span class="c"&gt;// Direct3D, through FXC or DXC&lt;/span&gt;
    &lt;span class="n"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;must&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dxil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dxil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;{}))&lt;/span&gt;    &lt;span class="c"&gt;// Direct3D 12, no DXC needed&lt;/span&gt;

    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SPIR-V %d bytes, MSL %d bytes, HLSL %d bytes, DXIL %d bytes&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spv&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metal&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hlslSrc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// must returns a backend's output and stops on its error.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;must&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;I&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&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;With naga v0.21.2 it prints &lt;code&gt;SPIR-V 1240 bytes, MSL 917 bytes, HLSL 667 bytes, DXIL 2960 bytes&lt;/code&gt;, then the Metal source. The zero value of every options struct is the safe choice: bounds checks are on and workgroup memory is zeroed. The SPIR-V, MSL and HLSL writers also give every loop an iteration limit, because a loop the driver's compiler can prove never ends is undefined behavior, and the compiler is then free to delete it, bounds checks included. Two of those show up in the Metal output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;while(true) {
    if (metal::all(loop_bound == uint2(0u))) { break; }
    ...
    result[metal::min(unsigned(_e6), (_buffer_sizes.size0 - 0 - 4) / 4)] = _e8 + 1u;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep an eye on that &lt;code&gt;metal::min&lt;/code&gt;. It's a fix from this month, and it comes back later.&lt;/p&gt;

&lt;p&gt;naga takes its IR and much of its design from &lt;a href="https://github.com/gfx-rs/wgpu/tree/trunk/naga" rel="noopener noreferrer"&gt;Rust naga&lt;/a&gt;, the shader compiler of Rust wgpu, which is the core of WebGPU in Firefox, Servo and Deno. We run Rust naga's 172-shader WGSL test corpus, compare our output with Rust's wherever Rust writes the same format (SPIR-V only structurally so far), and record every difference with its reason. Rust naga has no DXIL backend, so for DXIL the references are DXC and Mesa. naga's main user is &lt;a href="https://github.com/gogpu/wgpu" rel="noopener noreferrer"&gt;gogpu/wgpu&lt;/a&gt;, a WebGPU implementation in Go, which compiles its WGSL shaders with naga in its default pure-Go build.&lt;/p&gt;




&lt;h2&gt;
  
  
  Running the output instead
&lt;/h2&gt;

&lt;p&gt;After v0.20.3 we turned that experiment into a permanent test corpus: small compute shaders, each paired with the exact values a GPU has to write when it runs them. It lives in &lt;a href="https://github.com/gogpu/naga/tree/main/testdata/readback" rel="noopener noreferrer"&gt;&lt;code&gt;testdata/readback&lt;/code&gt;&lt;/a&gt; and has 55 cases today, mostly loops, function calls and local variables. Each case is a &lt;code&gt;.wgsl&lt;/code&gt; file and a &lt;code&gt;.json&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"desc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"for-loop counter declared in the loop header writes 1..4; the sentinel after them stays"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"entry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"workgroups"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bindings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"storage-rw"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"len"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fill"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0xDEADBEEF"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"expect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fill"&lt;/span&gt;&lt;span class="p"&gt;]]}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three habits made the corpus useful:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sentinels.&lt;/strong&gt; Every output buffer starts filled with &lt;code&gt;0xDEADBEEF&lt;/code&gt;, and the expected values say where it has to survive. A loop that runs one time too many, or a store through the wrong index, shows up as a missing sentinel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inputs from buffers.&lt;/strong&gt; Most cases read their inputs from a storage buffer, so the compiler can't fold the work away at compile time, and a few, like &lt;code&gt;collatz-lit.wgsl&lt;/code&gt; and &lt;code&gt;collatz-rt.wgsl&lt;/code&gt;, test the same computation both ways. The WebGPU conformance test suite uses the same trick.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Known failures with a card.&lt;/strong&gt; A case can list a backend it currently fails on, with the issue that tracks it. The run stays readable while the bug is open, and the entry goes away with the fix.&lt;/p&gt;

&lt;p&gt;naga can't drive a GPU itself, so the work is split. naga checks that every case is well formed and compiles on every backend, and runs spirv-val, glslangValidator, dxc and IDxcValidator on the output where they're installed. wgpu runs the corpus on its GPUs before naga releases that touch a backend, with a Go workspace (&lt;code&gt;go.work&lt;/code&gt;) pointing at the naga candidate, so a change runs on real hardware before it's tagged. That runner still lives outside the wgpu repository; moving it in is planned, and then anyone can run the corpus on their own GPU.&lt;/p&gt;

&lt;p&gt;For v0.21.1 the corpus ran on eight backend and adapter pairs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Backend&lt;/th&gt;
&lt;th&gt;Adapters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vulkan&lt;/td&gt;
&lt;td&gt;Intel Iris Xe, NVIDIA CMP 30HX&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct3D 12, HLSL through FXC&lt;/td&gt;
&lt;td&gt;Intel Iris Xe, NVIDIA GTX 1660 SUPER&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct3D 12, naga's DXIL&lt;/td&gt;
&lt;td&gt;Intel Iris Xe, NVIDIA GTX 1660 SUPER&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenGL ES&lt;/td&gt;
&lt;td&gt;Intel Iris Xe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;wgpu's software backend&lt;/td&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There's no Mac in that list. Metal is covered by wgpu's own tests on a macOS CI runner, which is where the third bug below turned up.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it caught
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Local arrays that forgot their initializers.&lt;/strong&gt; wgpu found this one with a vertex shader that picks its corners from local arrays. The same shape, cut down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@vertex
fn vs(@builtin(vertex_index) v: u32) -&amp;gt; @builtin(position) vec4f {
    var xs = array&amp;lt;f32, 6&amp;gt;(-1.0, 1.0, -1.0, -1.0, 1.0, 1.0);
    var ys = array&amp;lt;f32, 6&amp;gt;(-1.0, -1.0, 1.0, 1.0, -1.0, 1.0);
    return vec4f(xs[v % 6u], ys[v % 6u], 0.0, 1.0);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On naga v0.21.0 the DXIL for this has two &lt;code&gt;alloca&lt;/code&gt; instructions and not a single &lt;code&gt;store&lt;/code&gt;, so every index reads uninitialized memory, and the validator again says &lt;code&gt;VALID (S_OK)&lt;/code&gt;. wgpu's shader drew nothing on Intel. The bug was there since at least v0.17.16. Since v0.21.1 naga stores the initializers: 12 stores for this shader, checked on v0.21.2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A loop-carried variable read as if the loop ran once.&lt;/strong&gt; naga's DXIL backend turns local variables into SSA values where it can. When every access to a variable sat in one block inside a loop, and that block read the variable before writing it, the pass treated the block as running only once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;var x: u32;
for (var i = 0u; i &amp;lt; n; i++) {
    x += i + 1u;
    dst[i] = x;   // want 1, 3, 6, 10. DXIL wrote 1, 2, 3, 4.
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every iteration saw the value from before the loop. This one shipped in every release since v0.17.4. The corpus case for it also checks shapes the old code got right, such as variables read after the loop or updated only in branches, so the fix can't quietly break those.&lt;/p&gt;

&lt;p&gt;The v0.21.1 run compared every result with the same run on v0.21.0. 54 of the 55 cases ran on each of the eight pairs (one needs &lt;code&gt;shader-f16&lt;/code&gt;, which the runner skipped), 432 results in all. Seven changed, all on DXIL, all from fail to pass:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Backend and adapter&lt;/th&gt;
&lt;th&gt;v0.21.0&lt;/th&gt;
&lt;th&gt;v0.21.1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct3D 12 + naga's DXIL, Intel Iris Xe&lt;/td&gt;
&lt;td&gt;50 of 54 pass&lt;/td&gt;
&lt;td&gt;54 of 54&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct3D 12 + naga's DXIL, GTX 1660 SUPER&lt;/td&gt;
&lt;td&gt;51 of 54 pass&lt;/td&gt;
&lt;td&gt;54 of 54&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the other six pairs&lt;/td&gt;
&lt;td&gt;unchanged&lt;/td&gt;
&lt;td&gt;unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Not everything in that run was green, and both versions failed the same cases: two workgroup-memory cases on the CMP 30HX under Vulkan (not diagnosed yet; Intel passes them), one known OpenGL ES failure, and 15 of the 54 in wgpu's software backend (11 failures, 4 already known). For a release gate that's the point: nothing got worse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An out-of-bounds write on Metal.&lt;/strong&gt; wgpu has hand-written readback tests too. One of them, &lt;a href="https://github.com/gogpu/wgpu/blob/main/hal/allbackends/storage_oob_readback_test.go" rel="noopener noreferrer"&gt;&lt;code&gt;TestStorageBufferOutOfBoundsContained&lt;/code&gt;&lt;/a&gt;, writes past the end of a storage binding and then checks that the binding next to it is untouched. WGSL allows an implementation to clamp such an access, and wgpu's Metal backend asks naga to clamp every index into a buffer.&lt;/p&gt;

&lt;p&gt;On the macOS CI runner the test failed. For a plain &lt;code&gt;var&amp;lt;storage&amp;gt; a: array&amp;lt;u32&amp;gt;&lt;/code&gt;, naga's MSL used the index as it was, and the write landed in the next binding. Only a runtime-sized array at the end of a struct got clamped. v0.21.2 clamps every index into a runtime-sized array; that's the &lt;code&gt;metal::min&lt;/code&gt; from the output above. The unclamped MSL was perfectly valid MSL.&lt;/p&gt;




&lt;h2&gt;
  
  
  When Rust naga and the spec disagree
&lt;/h2&gt;

&lt;p&gt;While fixing the Metal bug I noticed that our bound was computed from the array's stride and Rust naga's from the element's size. I switched to Rust's form and called ours a defect.&lt;/p&gt;

&lt;p&gt;It wasn't. WGSL defines how many elements a runtime-sized array has, and calls it &lt;a href="https://www.w3.org/TR/WGSL/#nruntime" rel="noopener noreferrer"&gt;NRuntime&lt;/a&gt;: the buffer binding's size minus the array's offset, divided by the stride, rounded down. &lt;code&gt;arrayLength&lt;/code&gt; must return exactly that.&lt;/p&gt;

&lt;p&gt;The two forms differ when an element is smaller than its stride and the binding ends partway through the last stride. Take &lt;code&gt;array&amp;lt;vec3&amp;lt;f32&amp;gt;&amp;gt;&lt;/code&gt;: each element is 12 bytes and the stride is 16. On a 28-byte binding the spec says there is &lt;code&gt;truncate(28 / 16) = 1&lt;/code&gt; element. Rust naga's MSL computes &lt;code&gt;1 + (28 - 12) / 16 = 2&lt;/code&gt;, because a second &lt;code&gt;vec3&lt;/code&gt; would fit in bytes 16 to 27. That's true about memory and wrong about WGSL.&lt;/p&gt;

&lt;p&gt;The correction came from the wgpu side, and wgpu's &lt;code&gt;vec3&lt;/code&gt; test now pins it: its comment quotes the spec's NRuntime and requires &lt;code&gt;arrayLength&lt;/code&gt; to follow it. v0.21.2 uses the spec's count for both the clamp and &lt;code&gt;arrayLength&lt;/code&gt;, which is what naga's HLSL, DXIL, GLSL and SPIR-V output already did. On the macOS CI runner, a 28-byte &lt;code&gt;array&amp;lt;vec3&amp;lt;u32&amp;gt;&amp;gt;&lt;/code&gt; binding now reads &lt;code&gt;arrayLength&lt;/code&gt; back as 1 (&lt;a href="https://github.com/gogpu/naga/pull/151#issuecomment-6591629882" rel="noopener noreferrer"&gt;run result&lt;/a&gt;). The difference from Rust is written down in the code, in the tests that pin the bound, and in the CHANGELOG.&lt;/p&gt;

&lt;p&gt;So this is how we decide now. Where the spec defines a value or an error, the spec decides. Rust naga decides what the spec leaves open, like the shape of the IR or the wording of an error message. When we differ from Rust, we assume the bug is ours until we've read the spec. After that it might be theirs.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Run the output from the first day of a backend.&lt;/strong&gt; We had validators, golden files and Rust parity long before we had a GPU in the loop. All three check that the output looks right. Only running it checks that it is right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read a green validator as "legal", not "correct".&lt;/strong&gt; It's a gate worth having, and it says nothing about values.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make the expected values hard to fake.&lt;/strong&gt; Sentinels in every slot that shouldn't change, inputs from buffers, and several shapes per bug, including the ones the old code already handled.&lt;/p&gt;




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

&lt;p&gt;DXIL is opt-in in wgpu (&lt;code&gt;GOGPU_DX12_DXIL=1&lt;/code&gt;). The default Direct3D 12 path is HLSL through FXC, so DX12 users on that path were never affected by the DXIL bugs above.&lt;/p&gt;

&lt;p&gt;Before we call DXIL stable, the shaders of gg, our 2D graphics library, have to pass the validator, readback has to be green on Intel and NVIDIA, and no silent bug may be known. Two are known and open right now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;more shapes of the local-initializer bug: indexed vectors, arrays of arrays, zero-initialized arrays of vectors, and struct or array variables of a helper function called inside a loop;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;primitive_index&lt;/code&gt; reads wrong in fragment shaders on Intel, probably because the DXIL doesn't mark fragment system values as non-interpolated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If naga compiles one of your shaders wrong, the &lt;a href="https://github.com/gogpu/naga/tree/main/testdata/readback" rel="noopener noreferrer"&gt;corpus format&lt;/a&gt; is documented. A WGSL file plus the values it should produce is the most useful bug report we can get.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;naga&lt;/strong&gt;: &lt;a href="https://github.com/gogpu/naga" rel="noopener noreferrer"&gt;github.com/gogpu/naga&lt;/a&gt; · &lt;a href="https://pkg.go.dev/github.com/gogpu/naga@v0.21.2" rel="noopener noreferrer"&gt;pkg.go.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The &lt;a href="https://go.dev/blog/gopher" rel="noopener noreferrer"&gt;Go gopher&lt;/a&gt; was designed by Renée French and is licensed under &lt;a href="https://creativecommons.org/licenses/by/4.0/" rel="noopener noreferrer"&gt;CC BY 4.0&lt;/a&gt;.&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go get github.com/gogpu/naga@v0.21.2
go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/gogpu/naga/cmd/nagac@latest
nagac &lt;span class="nt"&gt;-o&lt;/span&gt; shader.spv shader.wgsl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>go</category>
      <category>testing</category>
      <category>gamedev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Go regexp too slow? I built coregex, a drop-in replacement. An audit found 16 bugs — here's what broke</title>
      <dc:creator>Andrey Kolkov</dc:creator>
      <pubDate>Fri, 09 Oct 2026 08:25:58 +0000</pubDate>
      <link>https://dev.to/kolkov/go-regexp-too-slow-i-built-coregex-a-drop-in-replacement-an-audit-found-16-bugs-heres-what-5e9i</link>
      <guid>https://dev.to/kolkov/go-regexp-too-slow-i-built-coregex-a-drop-in-replacement-an-audit-found-16-bugs-heres-what-5e9i</guid>
      <description>&lt;p&gt;Six months ago I published &lt;a href="https://dev.to/kolkov/gos-regexp-is-slow-so-i-built-my-own-up-to-3000x-faster-ck2"&gt;"Go's Regexp is Slow. So I Built My Own"&lt;/a&gt;. The project grew, got contributors, beat Rust on several patterns. Tested on Go 1.27, &lt;code&gt;go.mod&lt;/code&gt; requires 1.27+.&lt;/p&gt;

&lt;p&gt;Then I commissioned an independent code review — a full differential fuzz against stdlib plus manual audit of the engine internals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6% of random pattern/input pairs gave wrong answers.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not edge cases. Not theoretical. &lt;code&gt;(?i)привет&lt;/code&gt; didn't match &lt;code&gt;"привет"&lt;/code&gt; — its own literal. &lt;code&gt;\pL+&lt;/code&gt; on a 50KB file returned no matches while &lt;code&gt;MatchString&lt;/code&gt; returned &lt;code&gt;true&lt;/code&gt;. A 15-character pattern OOM'd the process at compile time.&lt;/p&gt;

&lt;p&gt;This is the story of what broke, why 65,000 lines of tests missed it, and what it took to fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bug that would have broken every HTTP router
&lt;/h2&gt;

&lt;p&gt;Issue &lt;a href="https://github.com/coregx/coregex/issues/166" rel="noopener noreferrer"&gt;#166&lt;/a&gt; from &lt;a href="https://github.com/vmihailenco" rel="noopener noreferrer"&gt;@vmihailenco&lt;/a&gt; — sat open for six weeks because I missed the notification buried in email noise, and only caught it reviewing the repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;coregex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`^x(a|bc)`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MatchString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"xbc"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a &lt;code&gt;false&lt;/code&gt; on a pattern that obviously matches. When I dug in, it wasn't one bug — it was an entire strategy producing wrong answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;^x(a|bc)              "xbc"        → false   (prefix ignored)
^HTTP/(1\.0|1\.1|2)   "HTTP/2"     → false   (every HTTP version check)
^/api/(users|posts)   "/api/users" → false   (every API router)
^(GET|POST) /         "GET x"      → true    (false positive!)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;BranchDispatch&lt;/code&gt; optimization dispatched by first byte, then verified the match with a simple matcher that couldn't handle real AST shapes. It was a mini-engine — one that said "yes" without being a real engine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; An optimization that answers "yes" independently must be a full engine. Otherwise it's a bug generator. Safe optimizations only say "no" quickly (prefilters, first-byte reject) and delegate "yes" to the real engine. Rust regex doesn't need branch dispatch — the lazy DFA transition table already &lt;em&gt;is&lt;/em&gt; a dispatch table at every state.&lt;/p&gt;

&lt;p&gt;Solution: deleted the entire strategy. -1077 lines. Anchored alternations now go through BoundedBacktracker with O(1) first-byte reject. 13 ns → 66 ns on short strings — still 1.5× faster than stdlib. The speed gap is a known issue; once the capture path is fixed (&lt;a href="https://github.com/coregx/coregex/issues/177" rel="noopener noreferrer"&gt;#177&lt;/a&gt;), anchored one-pass patterns can route through it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Case-insensitive Cyrillic didn't match its own literal
&lt;/h2&gt;

&lt;p&gt;The NFA compiler had this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;foldCase&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;isASCIILetter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// create alternation: upper + lower&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Non-ASCII runes with case-insensitive flag were compiled as &lt;strong&gt;exact uppercase match&lt;/strong&gt;. &lt;code&gt;syntax.Parse&lt;/code&gt; uppercases Cyrillic (&lt;code&gt;к&lt;/code&gt; → &lt;code&gt;К&lt;/code&gt;), so the NFA only matched &lt;code&gt;К&lt;/code&gt;, never &lt;code&gt;к&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But here's the weird part: &lt;code&gt;(?i)кот&lt;/code&gt; (3 Cyrillic runes) worked fine. &lt;code&gt;(?i)привет&lt;/code&gt; (6 runes) didn't. Same code path — how?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two different engines, one hiding the bug.&lt;/strong&gt; For 2-4 rune patterns, the cross-product of case variants fit within the Teddy prefilter's literal limit. Teddy matched all variants by exact bytes, bypassing the broken NFA entirely. For 1 rune and 5+ runes, the cross-product exceeded the limit → fell back to NFA → bug.&lt;/p&gt;

&lt;p&gt;The test suite had &lt;code&gt;(?i)кот&lt;/code&gt; — green. The real-world &lt;code&gt;(?i)привет&lt;/code&gt; — broken. A test that passes on a specific length doesn't test the code path you think it tests.&lt;/p&gt;

&lt;p&gt;Fix: &lt;code&gt;unicode.SimpleFold&lt;/code&gt; orbit for every rune, compiled as a split tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;foldOrbit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="kt"&gt;rune&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;rune&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;orbit&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;rune&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;unicode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SimpleFold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;unicode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SimpleFold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;orbit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orbit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;orbit&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c"&gt;// Σ → [Σ, σ, ς]  (Greek sigma, three forms)&lt;/span&gt;
&lt;span class="c"&gt;// K → [K, k, K]  (ASCII K, ASCII k, Kelvin sign)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Silent wrong results on large input
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;\pL+&lt;/code&gt; (match Unicode letters) on a 50KB file: &lt;code&gt;MatchString&lt;/code&gt; returned &lt;code&gt;true&lt;/code&gt;, but &lt;code&gt;FindAllStringIndex&lt;/code&gt; returned zero or one match instead of thousands.&lt;/p&gt;

&lt;p&gt;Root cause: &lt;code&gt;\pL&lt;/code&gt; compiles to 5842 NFA states (stdlib uses 3 instructions). On input over ~10KB, the BoundedBacktracker's visited table overflows and falls back to bidirectional DFA search. The forward DFA found the match end correctly, but the reverse DFA overflowed too — and its fallback ran the reverse NFA's PikeVM &lt;strong&gt;forward&lt;/strong&gt; on forward data. Reverse NFA expects reversed UTF-8 byte sequences; running it forward produces no matches.&lt;/p&gt;

&lt;p&gt;Fix: when the reverse DFA fails, fall back to the forward PikeVM's &lt;code&gt;SearchBetween&lt;/code&gt; instead. Correct on all input sizes now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost:&lt;/strong&gt; &lt;code&gt;\pL+&lt;/code&gt; on large inputs uses PikeVM fallback at ~50 μs/byte (8.4s on 120KB vs stdlib's 9ms). Correct, but impractical. The real fix is DFA cache clear-and-continue (as Rust does) plus compact UTF-8 range-trie compilation for Unicode classes. Both are tracked.&lt;/p&gt;




&lt;h2&gt;
  
  
  Empty matches that produced invalid UTF-8
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;re&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;coregex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`a*`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReplaceAllString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"日"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;// Expected: "-日-"&lt;/span&gt;
&lt;span class="c"&gt;// Got: invalid UTF-8 — rune sliced at byte boundaries&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After an empty match, the position advanced by &lt;strong&gt;one byte&lt;/strong&gt; instead of &lt;strong&gt;one rune&lt;/strong&gt;. &lt;code&gt;"日"&lt;/code&gt; is 3 bytes (&lt;code&gt;E6 97 A5&lt;/code&gt;), so instead of two empty matches (before and after the rune), we got four — one at each byte boundary, slicing the rune apart.&lt;/p&gt;

&lt;p&gt;26 call sites across 9 functions. One helper fixed them all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;advanceAfterEmpty&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="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;len&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="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DecodeRune&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;pos&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The 15-character DoS
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;coregex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`.(?:ac*d|ac)xy*`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;// 15 characters → &amp;gt;3 GB heap, &amp;gt;15 seconds, OOM kill&lt;/span&gt;
&lt;span class="c"&gt;// stdlib: ~7 μs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cloneRegexp&lt;/code&gt; in the literal extractor cloned both &lt;code&gt;Sub&lt;/code&gt; and &lt;code&gt;Sub0&lt;/code&gt; arrays. In &lt;code&gt;syntax.Regexp&lt;/code&gt;, &lt;code&gt;Sub&lt;/code&gt; usually aliases &lt;code&gt;Sub0[:1]&lt;/code&gt; — so every node with one child got cloned twice. Size grew as 2^depth.&lt;/p&gt;

&lt;p&gt;Fix: delete the &lt;code&gt;Sub0&lt;/code&gt; loop. One loop removed, compile time back to microseconds.&lt;/p&gt;

&lt;p&gt;For a library that claims "no ReDoS" — any service compiling user-supplied patterns was vulnerable to a 15-character denial of service.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the fuzz found (and didn't)
&lt;/h2&gt;

&lt;p&gt;The test suite had &lt;code&gt;hasUTF8CodepointDifference&lt;/code&gt; — a whitelist function that skipped &lt;em&gt;exactly&lt;/em&gt; the patterns where bugs lived. The fuzz harness compared each API against stdlib but filtered out multibyte input. We were testing the correct code paths and skipping the broken ones.&lt;/p&gt;

&lt;p&gt;After fixes, differential fuzz against stdlib: &lt;strong&gt;6.0% → 0.75%&lt;/strong&gt; divergence on random pattern/input pairs. The remaining 0.75% decomposes into three classes, each with its own issue:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Issue&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Negated classes match bytes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;\S{2}&lt;/code&gt; on &lt;code&gt;"К"&lt;/code&gt; → false positive&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/coregx/coregex/issues/174" rel="noopener noreferrer"&gt;#174&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lazy quantifiers = greedy&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;\d+?&lt;/code&gt; on &lt;code&gt;"11"&lt;/code&gt; → &lt;code&gt;[0 2]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/coregx/coregex/issues/175" rel="noopener noreferrer"&gt;#175&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leftmost priority divergence&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FindAllIndex&lt;/code&gt; ≠ &lt;code&gt;FindAllSubmatchIndex&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/coregx/coregex/issues/176" rel="noopener noreferrer"&gt;#176&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The benchmarks after fixes
&lt;/h2&gt;

&lt;p&gt;All 16 bugs from the audit are fixed; three pre-existing classes are tracked in &lt;a href="https://github.com/coregx/coregex/issues/178" rel="noopener noreferrer"&gt;#178&lt;/a&gt;. No benchmark regressions — verified on AMD EPYC via &lt;a href="https://github.com/kolkov/regex-bench" rel="noopener noreferrer"&gt;regex-bench&lt;/a&gt; CI (Go stdlib, coregex, and Rust regex on identical 6 MB workloads):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;vs stdlib&lt;/th&gt;
&lt;th&gt;vs Rust&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.*keyword.*&lt;/code&gt; (inner literal)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;527×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;27× faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.*\.txt&lt;/code&gt; (suffix)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;143×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.3× faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IPv4 octet alternation&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;236×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.6× faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;[\w]+&lt;/code&gt; (char class)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;11×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.2× faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;(?m)^/.*\.php&lt;/code&gt; (multiline)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;154×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~same&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Fuzz first, optimize second.&lt;/strong&gt; I had 65K lines of tests. A ~150-line differential harness found most of the bugs — random pattern × random input × compare with stdlib. Should have been day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Never whitelist what you can't explain.&lt;/strong&gt; &lt;code&gt;hasUTF8CodepointDifference&lt;/code&gt; was a flag that said "we know this is different." It should have been a bug count that said "we have N bugs to fix."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Optimizations that say "yes" are engines.&lt;/strong&gt; Prefilters, rejectors, first-byte checks — they say "no" quickly. Safe. The moment your optimization says "yes, this matches" without delegating to the real engine, you've built a second engine with half the test coverage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Real users find real bugs.&lt;/strong&gt; vmihailenco's one-line repro exposed a class of bugs that 65K lines of tests missed. Every regex library should have a "give me your pattern, I'll diff it against stdlib" command.&lt;/p&gt;




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

&lt;p&gt;Three correctness classes to close before v1.0 (&lt;a href="https://github.com/coregx/coregex/issues/178" rel="noopener noreferrer"&gt;#178&lt;/a&gt;). &lt;code&gt;FindSubmatch&lt;/code&gt; is &lt;a href="https://github.com/coregx/coregex/issues/177" rel="noopener noreferrer"&gt;3-10× slower than stdlib&lt;/a&gt; with 6-9 allocations per call — that's the primary API for log parsing and the most user-visible gap.&lt;/p&gt;

&lt;p&gt;The strategy architecture is sound; the bugs were in the NFA compiler's Unicode handling and in shortcuts around the engines.&lt;/p&gt;

&lt;p&gt;Contributions welcome. Especially on &lt;a href="https://github.com/coregx/coregex/issues/174" rel="noopener noreferrer"&gt;negated character classes&lt;/a&gt; — it's the biggest remaining divergence from stdlib, and the fix follows a pattern we've already applied for &lt;code&gt;.&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;coregex&lt;/strong&gt;: &lt;a href="https://github.com/coregx/coregex" rel="noopener noreferrer"&gt;github.com/coregx/coregex&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go get github.com/coregx/coregex@v0.12.27
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;regexp&lt;/span&gt; &lt;span class="s"&gt;"github.com/coregx/coregex"&lt;/span&gt;

&lt;span class="n"&gt;re&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;regexp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`(?i)привет`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MatchString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Привет"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// true (fixed in v0.12.26)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>go</category>
      <category>regex</category>
      <category>opensource</category>
      <category>performance</category>
    </item>
    <item>
      <title>Go 1.27 HTTP Router with Generic Methods and Zero-Alloc Routing: FURSY — What an Independent Audit Found at 94% Coverage</title>
      <dc:creator>Andrey Kolkov</dc:creator>
      <pubDate>Thu, 08 Oct 2026 01:01:54 +0000</pubDate>
      <link>https://dev.to/kolkov/go-127-http-router-with-generic-methods-and-zero-alloc-routing-fursy-what-an-independent-audit-800</link>
      <guid>https://dev.to/kolkov/go-127-http-router-with-generic-methods-and-zero-alloc-routing-fursy-what-an-independent-audit-800</guid>
      <description>

&lt;p&gt;We shipped FURSY v0.5.4 thinking it was production-ready. Ninety-four percent test coverage. Thirty-five findings from our internal audit, all fixed. Twelve CI checks across three operating systems. Zero-alloc routing under 100 ns/op.&lt;/p&gt;

&lt;p&gt;Then we handed the codebase to an independent auditor. They found the CORS middleware echoing back unfiltered headers to browsers. The SECURITY.md teaching six APIs that don't exist. Error responses claiming RFC 9457 compliance while returning &lt;code&gt;text/plain&lt;/code&gt;. A path parser that panics on Google's most widely used REST convention.&lt;/p&gt;

&lt;p&gt;This is the story of what went wrong, how we fixed it, and what fursy actually is after the cleanup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is Fursy
&lt;/h2&gt;

&lt;p&gt;FURSY (Fast Universal Routing SYstem) is an HTTP router for Go 1.27+ with three properties that don't usually come together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Generic methods on the router&lt;/strong&gt; -- &lt;code&gt;router.POST("/users", handler)&lt;/code&gt; where &lt;code&gt;handler&lt;/code&gt; receives a typed &lt;code&gt;*Box[CreateReq, UserRes]&lt;/code&gt;. Go 1.27 enabled this; to our knowledge, FURSY is the first Go router to ship generic methods on the router type.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Zero-allocation routing&lt;/strong&gt; -- sub-100 ns/op for static routes, 0 B/op, 0 allocs/op. The radix tree reuses a caller-provided param buffer from &lt;code&gt;sync.Pool&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;RFC 9457 Problem Details as the default&lt;/strong&gt; -- every auto-generated error (404, 405, 401, 429, 500) returns &lt;code&gt;application/problem+json&lt;/code&gt;. Not opt-in. Not "if you use our error type." Every response.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We built it as the successor to &lt;a href="https://github.com/go-ozzo/ozzo-routing" rel="noopener noreferrer"&gt;ozzo-routing&lt;/a&gt;, Qiang Xue's router from the Yii framework ecosystem. When Xue handed over the go-ozzo organization in mid-2026, FURSY was already running in our internal services.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Generic Methods Story
&lt;/h2&gt;

&lt;p&gt;Before Go 1.27, if you wanted type-safe handlers in Go, your options were ugly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Package-level functions -- verbose, no type inference&lt;/span&gt;
&lt;span class="n"&gt;fursy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;CreateUserReq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UserRes&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/users"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// Or abandon type safety -- manual binding, no compile-time guarantees&lt;/span&gt;
&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/users"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="n"&gt;CreateUserReq&lt;/span&gt;
    &lt;span class="c"&gt;// manual JSON decode, manual validation, manual error handling...&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Go 1.27 allows methods on concrete types to declare their own type parameters. This changes everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/users"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;fursy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;CreateUserReq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UserRes&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// c.ReqBody is *CreateUserReq -- already validated, already bound&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;createUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReqBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Created&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/users/"&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&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;UserRes&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;user&lt;/span&gt;&lt;span class="o"&gt;.&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;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&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;No explicit type parameters at the call site. The compiler infers &lt;code&gt;[CreateUserReq, UserRes]&lt;/code&gt; from the handler signature. Binding happens automatically before your handler runs -- if the JSON is malformed, the client gets a 400 before your code executes.&lt;/p&gt;

&lt;p&gt;This sounds minor until you have 200 endpoints. The reduction in ceremony is significant.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Audit Found: CORS Was Echoing Everything
&lt;/h2&gt;

&lt;p&gt;The most dangerous finding was in the CORS middleware. When a browser sends a preflight OPTIONS request, it includes &lt;code&gt;Access-Control-Request-Headers&lt;/code&gt; listing which headers the JavaScript wants to use. The server should respond with only the headers it actually allows.&lt;/p&gt;

&lt;p&gt;Our code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// cors.go -- BEFORE the fix&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;CORSConfig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;setPreflightHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reqHeaders&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allowedHeaders&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isPreflightAllowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reqHeaders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;allowed&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="c"&gt;// ...&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;allowedHeaders&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Access-Control-Allow-Headers"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reqHeaders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c"&gt;// BUG&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;See it? &lt;code&gt;isPreflightAllowed&lt;/code&gt; returns &lt;code&gt;allowedHeaders&lt;/code&gt; (the filtered list), but line 225 writes &lt;code&gt;reqHeaders&lt;/code&gt; (the raw request value). The browser sends &lt;code&gt;Content-Type, X-Evil-Header&lt;/code&gt;, we configured the server to only allow &lt;code&gt;Content-Type&lt;/code&gt;, and the response says "sure, X-Evil-Header is fine too."&lt;/p&gt;

&lt;p&gt;The fix was one word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Access-Control-Allow-Headers"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allowedHeaders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c"&gt;// FIXED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One word. Ninety-four percent test coverage didn't catch it because every test requested only headers that were in the allowlist. The regression test we added:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestCORS_PreflightFilteredHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fursy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CORSWithConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CORSConfig&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;AllowOrigins&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"https://example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;AllowMethods&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"GET,POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;AllowHeaders&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Content-Type,Authorization"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="c"&gt;// ... setup ...&lt;/span&gt;

    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;httptest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OPTIONS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/api"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NoBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Access-Control-Request-Headers"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Content-Type, X-Evil-Header"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;

    &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Access-Control-Allow-Headers"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"X-Evil-Header"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"preflight echoed disallowed header: got %q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&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;Lesson: test the &lt;strong&gt;negative&lt;/strong&gt; case. Every CORS test we had sent only valid headers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Documentation Was Lying
&lt;/h2&gt;

&lt;p&gt;The auditor checked whether our docs matched the actual API. They didn't.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Document said&lt;/th&gt;
&lt;th&gt;Reality&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;router.Run(":8080")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Method doesn't exist. Never did.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fursy.CSRF(fursy.CSRFConfig{...})&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No CSRF middleware&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fursy.Timeout(30 * time.Second)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Doesn't exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fursy.BodyLimit(10 * 1024 * 1024)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Doesn't exist. Body limit is &lt;code&gt;router.SetMaxBodySize()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fursy.HTTPSRedirect()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Doesn't exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fursy.RateLimit(100, time.Minute)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Actual signature: &lt;code&gt;middleware.RateLimit(float64, int)&lt;/code&gt;. This wouldn't even compile.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;import "encoding/json/v2"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Code uses &lt;code&gt;encoding/json&lt;/code&gt;. Go 1.27 made v2 the default behind this import.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SECURITY.md: supported versions 0.1.x, 0.2.x&lt;/td&gt;
&lt;td&gt;Current version: 0.5.4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Six non-existent APIs documented as if they were real. An LLM coding assistant reading our &lt;code&gt;llms.txt&lt;/code&gt; would confidently generate code that doesn't compile. A developer copying from SECURITY.md would get build errors.&lt;/p&gt;

&lt;p&gt;How did this happen? The docs were written during the design phase and never reconciled with what was actually implemented. Classic drift -- the code evolves, the prose doesn't.&lt;/p&gt;

&lt;p&gt;We cleaned out the worst offenders. Full documentation parity is still in progress -- doc drift is a hydra, and every time we grep for one stale reference, two more surface. The rule going forward: if the API doesn't exist in the source tree, it doesn't exist in the docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  RFC 9457: Claiming It vs. Being It
&lt;/h2&gt;

&lt;p&gt;Fursy's README said "Native RFC 9457 Problem Details." The auditor checked what &lt;code&gt;GET /nonexistent&lt;/code&gt; actually returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;404&lt;/span&gt; &lt;span class="ne"&gt;Not Found&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;text/plain; charset=utf-8&lt;/span&gt;

Not Found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain text. Not JSON. Not RFC 9457. The "native" Problem Details only activated when a handler explicitly returned a &lt;code&gt;fursy.Problem&lt;/code&gt;. Every auto-generated error -- 404, 405, 413, 415, 400, 500, and all middleware rejections -- was &lt;code&gt;text/plain&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We fixed this across all paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// BEFORE -- text/plain&lt;/span&gt;
&lt;span class="n"&gt;terminalHandler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusNotFound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Not Found"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// AFTER -- RFC 9457&lt;/span&gt;
&lt;span class="n"&gt;terminalHandler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NewProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusNotFound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Not Found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&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;Same change in JWT (401), BasicAuth (401), RateLimit (429), CircuitBreaker (503), Recovery (500). Every error response is now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"about:blank"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Not Found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;Content-Type: application/problem+json&lt;/code&gt;. This is a breaking change -- clients parsing &lt;code&gt;text/plain&lt;/code&gt; error bodies will break. We shipped it in v0.6.0 because the project is pre-1.0 and the previous behavior was a lie.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Colon That Broke Google's API Convention
&lt;/h2&gt;

&lt;p&gt;A developer migrating from ozzo-routing reported that route registration panics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/clients:getOrganization/:id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handler1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/clients:getAddress/:id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handler2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;// panic: conflicting wildcard names: getOrganization vs getAddress&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;:&lt;/code&gt; in &lt;code&gt;clients:getOrganization&lt;/code&gt; was being parsed as a wildcard param marker. But this is a &lt;a href="https://google.aip.dev/136" rel="noopener noreferrer"&gt;Google AIP-136 custom method&lt;/a&gt; -- the most widely adopted enterprise REST convention for non-CRUD operations. Google Firestore alone has 20+ endpoints using this pattern: &lt;code&gt;/documents:commit&lt;/code&gt;, &lt;code&gt;/documents:batchGet&lt;/code&gt;, &lt;code&gt;/documents:rollback&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;RFC 3986 Section 3.3 lists &lt;code&gt;:&lt;/code&gt; as a legal path character. It doesn't need escaping. The &lt;code&gt;:param&lt;/code&gt; convention is a Sinatra invention from 2007, not an RFC.&lt;/p&gt;

&lt;p&gt;The fix: colons are wildcards only at segment start (after &lt;code&gt;/&lt;/code&gt; or position 0):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// BEFORE&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;findWildcardIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;':'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'*'&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;  &lt;span class="c"&gt;// any position&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// AFTER&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;findWildcardIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&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="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;':'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'*'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;  &lt;span class="c"&gt;// segment start only&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For context: Gin fixed this in v1.11.0 after &lt;a href="https://github.com/gin-gonic/gin/issues/1432" rel="noopener noreferrer"&gt;six years of open issues&lt;/a&gt;. Echo needed &lt;a href="https://github.com/labstack/echo/pull/3113" rel="noopener noreferrer"&gt;three separate PRs&lt;/a&gt;. httprouter &lt;a href="https://github.com/julienschmidt/httprouter/issues/196" rel="noopener noreferrer"&gt;never fixed it&lt;/a&gt;. Express.js has had it &lt;a href="https://github.com/expressjs/express/issues/3857" rel="noopener noreferrer"&gt;open since 2019&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Form Binding Errors: 500 Instead of 400
&lt;/h2&gt;

&lt;p&gt;Submitting &lt;code&gt;age=abc&lt;/code&gt; where &lt;code&gt;age&lt;/code&gt; is an &lt;code&gt;int&lt;/code&gt; field returned 500 Internal Server Error. The strconv parse error wasn't wrapped in our &lt;code&gt;DecodeError&lt;/code&gt; type, so the error handler fell through to the catch-all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// BEFORE -- raw error, caught by nothing&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;setField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"set field %s error: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;structField&lt;/span&gt;&lt;span class="o"&gt;.&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;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// AFTER -- wrapped, caught as 400&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;setField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;DecodeError&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Err&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"field %s: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;structField&lt;/span&gt;&lt;span class="o"&gt;.&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;err&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;A user sending &lt;code&gt;?age=abc&lt;/code&gt; now gets 400 Bad Request with a Problem Details body explaining which field failed. Not a 500 that makes your monitoring think the server is crashing.&lt;/p&gt;

&lt;p&gt;This fix covers &lt;code&gt;setField&lt;/code&gt; type mismatches. Errors from &lt;code&gt;ParseForm&lt;/code&gt;/&lt;code&gt;ParseMultipartForm&lt;/code&gt; itself (malformed percent-encoding, corrupt multipart boundaries) were also wrapped in v0.6.2.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Fursy Doesn't Do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No ORM&lt;/strong&gt; -- use &lt;a href="https://github.com/coregx/relica" rel="noopener noreferrer"&gt;Relica&lt;/a&gt; or GORM or raw &lt;code&gt;database/sql&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No migrations&lt;/strong&gt; -- use goose or atlas&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No template rendering&lt;/strong&gt; -- this is an API router, not a web framework&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No WebSocket/SSE built-in&lt;/strong&gt; -- SSE works via the &lt;a href="https://github.com/coregx/stream" rel="noopener noreferrer"&gt;stream&lt;/a&gt; plugin; WebSocket upgrade requires the base responseWriter (no middleware wrappers in the chain) until stream adopts &lt;code&gt;http.NewResponseController&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No process management&lt;/strong&gt; -- use &lt;a href="https://github.com/grpmsoft/daemon" rel="noopener noreferrer"&gt;daemon&lt;/a&gt; if you need it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No CSRF middleware&lt;/strong&gt; -- we removed the docs that claimed it existed. If you need CSRF, use gorilla/csrf or write your own. We'll add it when we have a design we're confident in, not before.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fursy vs. Other Go Routers (2026)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Router&lt;/th&gt;
&lt;th&gt;Go Version&lt;/th&gt;
&lt;th&gt;Type Safety&lt;/th&gt;
&lt;th&gt;Allocs/op&lt;/th&gt;
&lt;th&gt;Error Format&lt;/th&gt;
&lt;th&gt;Colon Literal&lt;/th&gt;
&lt;th&gt;Dependencies&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Go 1.22+ stdlib&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.22+&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{param}&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;~3&lt;/td&gt;
&lt;td&gt;text/plain&lt;/td&gt;
&lt;td&gt;Yes (&lt;code&gt;{param}&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;chi v5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.22+&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{param}&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;~2&lt;/td&gt;
&lt;td&gt;text/plain&lt;/td&gt;
&lt;td&gt;Yes (&lt;code&gt;{param}&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Gin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.18+&lt;/td&gt;
&lt;td&gt;No generics&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;custom JSON&lt;/td&gt;
&lt;td&gt;Yes (v1.11+)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Echo&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.18+&lt;/td&gt;
&lt;td&gt;No generics&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;custom JSON&lt;/td&gt;
&lt;td&gt;Yes (escaped)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fiber&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.21+&lt;/td&gt;
&lt;td&gt;No generics&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;text/plain&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;fasthttp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;fursy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.27+&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Generic methods&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 (plain) / 2 (generic)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;RFC 9457&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes (v0.6.2)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2 (JWT, rate)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fursy requires Go 1.27. That's a hard requirement. If you're on 1.22 or earlier, use chi or the stdlib mux. If you want zero dependencies, use the stdlib. If you need the fastest raw throughput, Fiber on fasthttp wins.&lt;/p&gt;

&lt;p&gt;Fursy is for teams that want type-safe handlers, zero-alloc routing, and RFC-compliant error responses in a single package, and can target Go 1.27+.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Core library&lt;/td&gt;
&lt;td&gt;~8,200 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test code&lt;/td&gt;
&lt;td&gt;~16,000 lines (2:1 ratio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test functions&lt;/td&gt;
&lt;td&gt;510+ (core + plugins)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Benchmarks&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coverage&lt;/td&gt;
&lt;td&gt;93.8% core, 96.5% middleware&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static route lookup&lt;/td&gt;
&lt;td&gt;&amp;lt;100 ns/op, 0 alloc&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parametric route lookup&lt;/td&gt;
&lt;td&gt;&amp;lt;140 ns/op, 0 alloc&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production dependencies&lt;/td&gt;
&lt;td&gt;2 (golang-jwt, x/time)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plugin dependencies&lt;/td&gt;
&lt;td&gt;Isolated per plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI checks&lt;/td&gt;
&lt;td&gt;12 (3 OS, lint, format, vuln, 4 plugins, examples, codecov)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External audit findings&lt;/td&gt;
&lt;td&gt;45 claims checked, 33 confirmed, all fixed in v0.6.0-v0.6.2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  router.GET Is a Generic Method Now
&lt;/h3&gt;

&lt;p&gt;After v0.5.0, &lt;code&gt;router.GET&lt;/code&gt; expects &lt;code&gt;Handler[Req, Res]&lt;/code&gt; (a function taking &lt;code&gt;*Box[Req, Res]&lt;/code&gt;), not &lt;code&gt;HandlerFunc&lt;/code&gt; (taking &lt;code&gt;*Context&lt;/code&gt;). For plain handlers without type-safe binding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// This does NOT compile:&lt;/span&gt;
&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/health"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;fursy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&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="c"&gt;// Use Handle for plain handlers:&lt;/span&gt;
&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/health"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;fursy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&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="c"&gt;// Or use the generic form with Empty types:&lt;/span&gt;
&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/health"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;fursy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;fursy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fursy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="kt"&gt;error&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Plugins Have Their Own go.mod
&lt;/h3&gt;

&lt;p&gt;Each plugin (&lt;code&gt;plugins/opentelemetry&lt;/code&gt;, &lt;code&gt;plugins/database&lt;/code&gt;, &lt;code&gt;plugins/stream&lt;/code&gt;, &lt;code&gt;plugins/validator&lt;/code&gt;) is a separate Go module. When upgrading fursy, update the plugin's &lt;code&gt;go.mod&lt;/code&gt; too:&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;cd &lt;/span&gt;plugins/stream
go get github.com/coregx/fursy@v0.6.2
go mod tidy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  CORS Must Be Global
&lt;/h3&gt;

&lt;p&gt;CORS middleware must be registered at the router level (&lt;code&gt;router.Use()&lt;/code&gt;), not on a group. Preflight OPTIONS requests hit the global middleware chain before route matching. Group-level CORS will miss them. This matches Gin, Echo, and Chi behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Second Pass
&lt;/h2&gt;

&lt;p&gt;The first audit found the CORS echo, the phantom docs, and the text/plain lie. We fixed those in v0.6.0. Then we sent the fix branch back for re-validation. The second pass found 11 more: an open redirect in the trailing-slash handler, WebSocket hijack broken by our own response wrapper, the circuit breaker counting 404s as backend failures, and the OpenAPI generator not knowing about the colon fix it shipped alongside. All fixed in v0.6.2.&lt;/p&gt;

&lt;p&gt;The pattern is humbling: every fix creates a surface for the next bug. The CORS allowlist fix shipped next to a default that had been blocking JSON POST preflights all along -- no test ever sent one. The colon fix in the radix tree didn't propagate to the OpenAPI converter. The response wrapper that tracked &lt;code&gt;written&lt;/code&gt; didn't implement &lt;code&gt;Hijack&lt;/code&gt;. Ninety-four percent coverage catches syntax, not semantics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go get github.com/coregx/fursy@v0.6.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The project is at v0.6.2. The API is not frozen -- we're pre-1.0 and will break things when the design demands it. The &lt;a href="https://github.com/coregx/fursy/blob/main/llms.txt" rel="noopener noreferrer"&gt;llms.txt&lt;/a&gt; documents every API pattern for both developers and AI coding assistants.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/coregx/fursy" rel="noopener noreferrer"&gt;coregx/fursy&lt;/a&gt; -- Type-safe HTTP router for Go 1.27+. RFC 9457 errors. Zero-alloc routing. MIT license.&lt;/p&gt;

</description>
      <category>go</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>Go SQL Query Builder Without Dependencies: Relica, the ozzo-dbx Successor — What Broke on PostgreSQL and SQLite</title>
      <dc:creator>Andrey Kolkov</dc:creator>
      <pubDate>Sat, 03 Oct 2026 09:05:52 +0000</pubDate>
      <link>https://dev.to/kolkov/go-sql-query-builder-without-dependencies-relica-the-ozzo-dbx-successor-what-broke-on-1b33</link>
      <guid>https://dev.to/kolkov/go-sql-query-builder-without-dependencies-relica-the-ozzo-dbx-successor-what-broke-on-1b33</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/coregx/relica" rel="noopener noreferrer"&gt;Relica&lt;/a&gt; is a zero-dependency SQL query builder for Go (PostgreSQL, MySQL, SQLite), the successor to &lt;a href="https://github.com/go-ozzo/ozzo-dbx" rel="noopener noreferrer"&gt;ozzo-dbx&lt;/a&gt;. I wrote it from scratch, keeping the API design of ozzo-dbx — the query builder by Qiang Xue, creator of Yii framework. Qiang hadn't maintained it for years, and when he handed me the &lt;a href="https://github.com/go-ozzo" rel="noopener noreferrer"&gt;go-ozzo organization&lt;/a&gt; in mid-2026, Relica was already in production.&lt;/p&gt;

&lt;p&gt;We run Relica in production across multiple services on PostgreSQL, MySQL and SQLite, and an external code auditor recently found six critical bugs in one session. This article is about those bugs, how they got there, and the patterns that prevent them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug That Would Have Woken Me at 3 AM
&lt;/h2&gt;

&lt;p&gt;An external auditor ran our codebase on Go 1.27.1 (we support 1.25+) with the race detector and custom reproducing tests. One finding hit hard: &lt;code&gt;One()&lt;/code&gt; was checking &lt;code&gt;rows.Next()&lt;/code&gt; and returning &lt;code&gt;ErrNotFound&lt;/code&gt; without ever calling &lt;code&gt;rows.Err()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What this means in production: when PostgreSQL drops a connection mid-query — context cancellation, network blip, anything — &lt;code&gt;rows.Next()&lt;/code&gt; returns false, and you get "not found" instead of "database unreachable." Your monitoring shows a spike in 404s. Your dashboards look fine. Users intermittently can't find their own profiles.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Before v0.17.2 (broken):&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ErrNotFound&lt;/span&gt;  &lt;span class="c"&gt;// masks real errors!&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// After v0.17.2:&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rowErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;rowErr&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rowErr&lt;/span&gt;  &lt;span class="c"&gt;// context.Canceled, connection reset, etc.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ErrNotFound&lt;/span&gt;  &lt;span class="c"&gt;// genuinely no rows&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix was three lines. The lesson: &lt;code&gt;ErrNotFound&lt;/code&gt; is a business outcome, not an infrastructure failure. Test for it explicitly in your handlers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;
&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&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;requestedID&lt;/span&gt;
&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;relica&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrNotFound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrUserNotFound&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"fetch user: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// real DB error&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  PostgreSQL &lt;code&gt;$N&lt;/code&gt; Placeholder Collision in Subqueries (&lt;code&gt;bind message supplies N parameters&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;If you've ever seen &lt;code&gt;bind message supplies 3 parameters, but prepared statement requires 2&lt;/code&gt; on PostgreSQL — this might be why.&lt;/p&gt;

&lt;p&gt;Before v0.17.2, Relica built SQL in multiple passes. Each subquery numbered its own placeholders from &lt;code&gt;$1&lt;/code&gt;. When a subquery was embedded inside an outer query, the numbers collided:&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;-- What Relica generated (broken):&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"id"&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nv"&gt;"user_id"&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"orders"&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="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&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="c1"&gt;-- args: [true, "done", 100]&lt;/span&gt;
&lt;span class="c1"&gt;-- $1 used for both 'true' and 'done'!&lt;/span&gt;

&lt;span class="c1"&gt;-- What it should be:&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"id"&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nv"&gt;"user_id"&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"orders"&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="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix required rearchitecting the builder. We split &lt;code&gt;buildSQL&lt;/code&gt; into two methods: &lt;code&gt;renderSQL&lt;/code&gt; assembles the entire query with &lt;code&gt;?&lt;/code&gt; placeholders, then &lt;code&gt;buildSQL&lt;/code&gt; does a single &lt;code&gt;replacePlaceholders&lt;/code&gt; pass to number them sequentially. One lexer, one pass, no collisions.&lt;/p&gt;

&lt;p&gt;The same fix caught two more bugs we hadn't noticed: &lt;code&gt;GroupByExpr&lt;/code&gt; and &lt;code&gt;OrderByExpr&lt;/code&gt; with parameters weren't being renumbered on PostgreSQL at all — the &lt;code&gt;?&lt;/code&gt; stayed in the output SQL verbatim.&lt;/p&gt;

&lt;p&gt;Squirrel hit the same bug class in 2019 (&lt;a href="https://github.com/Masterminds/squirrel/issues/203" rel="noopener noreferrer"&gt;#203&lt;/a&gt;) — closed with a workaround. The maintainer called it a design issue that can't be fixed without breaking compatibility. Our &lt;code&gt;renderSQL&lt;/code&gt; approach solves it cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Statement Cache: The Race Nobody Tested
&lt;/h2&gt;

&lt;p&gt;Our statement cache used &lt;code&gt;Get()&lt;/code&gt; and &lt;code&gt;Set()&lt;/code&gt; — standard LRU. Two goroutines prepare the same query simultaneously:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Goroutine A: &lt;code&gt;Get("SELECT...")&lt;/code&gt; → miss&lt;/li&gt;
&lt;li&gt;Goroutine B: &lt;code&gt;Get("SELECT...")&lt;/code&gt; → miss (A hasn't Set yet)&lt;/li&gt;
&lt;li&gt;A: &lt;code&gt;Prepare()&lt;/code&gt; → stmt1, &lt;code&gt;Set("SELECT...", stmt1)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;B: &lt;code&gt;Prepare()&lt;/code&gt; → stmt2, &lt;code&gt;Set("SELECT...", stmt2)&lt;/code&gt; → &lt;strong&gt;closes stmt1&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A: &lt;code&gt;stmt1.Query()&lt;/code&gt; → &lt;code&gt;sql: statement is closed&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The auditor reproduced this under concurrent load — hundreds of &lt;code&gt;sql: statement is closed&lt;/code&gt; errors on cold start. The fix: &lt;code&gt;GetOrSet()&lt;/code&gt; — an atomic cache-or-insert. The loser of the race closes its own statement (which nobody else has seen) and uses the cached one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inserted&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetOrSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;inserted&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c"&gt;// lost the race — ours is unobserved&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Patterns That Actually Work
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Model() for CRUD, Builder for Everything Else
&lt;/h3&gt;

&lt;p&gt;The first version of Relica only had the fluent builder — that's what ozzo-dbx had. After months of production use, I added &lt;code&gt;Model()&lt;/code&gt; for the most common case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Quick PK lookup — one line&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// Need column selection or filters — full builder chain&lt;/span&gt;
&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;User&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="m"&gt;42&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relica&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"deleted_at"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Model()&lt;/code&gt; reads the primary key from struct fields, figures out the table name, and scans the result. The builder is for everything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Expression API Over String Conditions
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Portable across PostgreSQL, MySQL, SQLite:&lt;/span&gt;
&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relica&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relica&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GreaterOrEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"age"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MinAge&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relica&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"deleted_at"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c"&gt;// Instead of:&lt;/span&gt;
&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"status = ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c"&gt;// works, but less explicit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Expression API handles identifier quoting and placeholder numbering internally. It also catches more errors at build time — &lt;code&gt;Eq("status", nil)&lt;/code&gt; generates &lt;code&gt;IS NULL&lt;/code&gt;, not a broken &lt;code&gt;= NULL&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  ForUpdate Inside Transactions
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Transactional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;relica&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="n"&gt;Account&lt;/span&gt;
    &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="o"&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;accountID&lt;/span&gt;
    &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ForUpdate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Balance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ErrInsufficientFunds&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Balance&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"balance"&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;code&gt;ForUpdate()&lt;/code&gt; outside a transaction is pointless — the lock releases immediately. On SQLite, it's silently ignored because SQLite uses database-level locking. Your code stays portable.&lt;/p&gt;

&lt;h3&gt;
  
  
  QueryHook for Everything Observable
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;relica&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"postgres"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;relica&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithQueryHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="n"&gt;relica&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;queryDuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithLabelValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Operation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Seconds&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Millisecond&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WarnContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"slow query"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"sql"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SQL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Milliseconds&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&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;One hook replaced our logger, metrics middleware, and slow-query detector. Zero overhead when not configured — it's a nil check.&lt;/p&gt;

&lt;h3&gt;
  
  
  Subqueries Need .AsExpression()
&lt;/h3&gt;

&lt;p&gt;The public &lt;code&gt;*SelectQuery&lt;/code&gt; doesn't implement &lt;code&gt;Expression&lt;/code&gt; directly. This compiles but treats the query object as a bind parameter value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Wrong — compiles, generates garbage SQL:&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"users"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relica&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;In&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;All&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// Correct:&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"users"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relica&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;In&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AsExpression&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;All&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same for &lt;code&gt;Exists()&lt;/code&gt;. The &lt;code&gt;.AsExpression()&lt;/code&gt; call is the explicit conversion from query object to SQL fragment.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Didn't Build
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Migrations.&lt;/strong&gt; Use &lt;a href="https://github.com/pressly/goose" rel="noopener noreferrer"&gt;goose&lt;/a&gt; or &lt;a href="https://atlasgo.io/" rel="noopener noreferrer"&gt;atlas&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relations / eager loading.&lt;/strong&gt; Write JOINs explicitly — that's the point of a query builder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compile-time type safety.&lt;/strong&gt; If you want that, &lt;a href="https://github.com/sqlc-dev/sqlc" rel="noopener noreferrer"&gt;sqlc&lt;/a&gt; or &lt;a href="https://github.com/go-jet/jet" rel="noopener noreferrer"&gt;jet&lt;/a&gt; are better tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema management.&lt;/strong&gt; We build queries, not manage databases.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Relica vs Squirrel vs sqlx vs GORM vs sqlc (2026)
&lt;/h2&gt;

&lt;p&gt;If you're choosing between Go SQL libraries in 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Style&lt;/th&gt;
&lt;th&gt;When to pick it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/go-jet/jet" rel="noopener noreferrer"&gt;go-jet&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Code generation&lt;/td&gt;
&lt;td&gt;Large schemas, compile-time type safety, IDE autocompletion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/sqlc-dev/sqlc" rel="noopener noreferrer"&gt;sqlc&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Code generation&lt;/td&gt;
&lt;td&gt;Static queries only, strongest type guarantees&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/doug-martin/goqu" rel="noopener noreferrer"&gt;goqu&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Fluent builder&lt;/td&gt;
&lt;td&gt;Mature, battle-tested, complex dynamic filters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/bokwoon95/sq" rel="noopener noreferrer"&gt;sq&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Generics/callback&lt;/td&gt;
&lt;td&gt;Zero-reflection scanning, generics-first API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://gorm.io/" rel="noopener noreferrer"&gt;GORM&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Full ORM&lt;/td&gt;
&lt;td&gt;Relations, migrations, hooks — if you want the full stack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Relica&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fluent + Model API&lt;/td&gt;
&lt;td&gt;Zero deps, transparent SQL, built-in LRU cache, Stripe-like IDs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/Masterminds/squirrel" rel="noopener noreferrer"&gt;Squirrel&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Fluent builder&lt;/td&gt;
&lt;td&gt;Maintenance mode since 2024. Known subquery placeholder limitation (&lt;a href="https://github.com/Masterminds/squirrel/issues/203" rel="noopener noreferrer"&gt;#203&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/jmoiron/sqlx" rel="noopener noreferrer"&gt;sqlx&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Scanning layer&lt;/td&gt;
&lt;td&gt;Last release v1.4.0 (2024). A &lt;a href="https://github.com/go-sqlx/sqlx" rel="noopener noreferrer"&gt;community fork&lt;/a&gt; exists&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you need compile-time typed SQL — go-jet or sqlc are better tools. If you want a fluent builder without external deps that shows you exactly what SQL it generates — that's Relica.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Library code&lt;/td&gt;
&lt;td&gt;14,101 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test code&lt;/td&gt;
&lt;td&gt;37,902 lines (2.7:1 ratio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test functions&lt;/td&gt;
&lt;td&gt;1,136&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coverage&lt;/td&gt;
&lt;td&gt;85%+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production dependencies&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI&lt;/td&gt;
&lt;td&gt;Unit: Linux, macOS, Windows. Integration: PostgreSQL, MySQL, SQLite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Releases&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External audit bugs found&lt;/td&gt;
&lt;td&gt;6 (all fixed in v0.17.2)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Always implement &lt;code&gt;TableName()&lt;/code&gt;.&lt;/strong&gt; Default inference adds &lt;code&gt;s&lt;/code&gt; and lowercases — &lt;code&gt;Category&lt;/code&gt; → &lt;code&gt;categorys&lt;/code&gt;, not &lt;code&gt;categories&lt;/code&gt;. I'm not a fan of plural table names, and even if you are, naive pluralization won't work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"category"&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;&lt;code&gt;Select("n.*")&lt;/code&gt; was broken until v0.17.1.&lt;/strong&gt; The wildcard was quoted as &lt;code&gt;"n"."*"&lt;/code&gt; — valid identifier syntax, invalid wildcard. If you're on an older version, use explicit column names.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero-dependency means zero-dependency.&lt;/strong&gt; &lt;code&gt;go.mod&lt;/code&gt; has no &lt;code&gt;require&lt;/code&gt; block. Test and benchmark modules are separate. I've had people not believe this until they check.&lt;/p&gt;




&lt;p&gt;Relica is at &lt;a href="https://github.com/coregx/relica/releases/tag/v0.17.3" rel="noopener noreferrer"&gt;v0.17.3&lt;/a&gt;. The &lt;a href="https://github.com/coregx/relica/blob/main/AGENTS.md" rel="noopener noreferrer"&gt;AGENTS.md&lt;/a&gt; has every API pattern — it's written for both humans and AI coding assistants.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Found a bug? &lt;a href="https://github.com/coregx/relica/issues" rel="noopener noreferrer"&gt;Open an issue&lt;/a&gt;. Have a pattern that works well? Tell us in &lt;a href="https://github.com/coregx/relica/discussions" rel="noopener noreferrer"&gt;discussions&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>database</category>
      <category>postgres</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Go Daemon for MCP Servers: Shared Background Processes Without systemd or Other Supervisors</title>
      <dc:creator>Andrey Kolkov</dc:creator>
      <pubDate>Wed, 23 Sep 2026 18:28:10 +0000</pubDate>
      <link>https://dev.to/kolkov/go-daemon-for-mcp-servers-shared-background-processes-without-systemd-or-other-supervisors-6d</link>
      <guid>https://dev.to/kolkov/go-daemon-for-mcp-servers-shared-background-processes-without-systemd-or-other-supervisors-6d</guid>
      <description>&lt;p&gt;My team builds &lt;a href="https://github.com/gogpu" rel="noopener noreferrer"&gt;GoGPU&lt;/a&gt; — a pure-Go GPU ecosystem (1.25M+ LOC, 21 repositories) &lt;a href="https://github.com/gogpu/ui" rel="noopener noreferrer"&gt;gogpu/ui&lt;/a&gt; — a GPU-accelerated UI framework, and &lt;a href="https://github.com/born-ml" rel="noopener noreferrer"&gt;Born ML&lt;/a&gt; — a machine learning framework on top of it. Working on codebases this size with AI agents requires serious tooling: you can't just grep and hope. So we built GLIDE — a headless Go IDE written in Go, with 65 MCP tools, a knowledge graph, and full gopls integration, designed to give AI coding agents — Claude Code, Cursor, Copilot — semantic understanding of Go code. It's an internal tool we're preparing for open source, still in progress.&lt;/p&gt;

&lt;p&gt;Here's where it gets expensive. GLIDE runs as an MCP server, and each AI agent spawns its own instance. Each instance starts a gopls language server. gopls eats about 1–2 GB of RAM per workspace. Run six agents on GoGPU — congrats, you just burned 12 GB on identical type-checking of the same modules.&lt;/p&gt;

&lt;p&gt;The obvious fix: share one gopls across all agents. Run GLIDE as a background daemon, let agents connect on demand.&lt;/p&gt;

&lt;p&gt;The non-obvious part: making that daemon reliable on three operating systems, under concurrent access from processes that die without warning, without adding a single dependency to the binary.&lt;/p&gt;

&lt;p&gt;This is the story of &lt;a href="https://github.com/grpmsoft/daemon" rel="noopener noreferrer"&gt;&lt;code&gt;grpmsoft/daemon&lt;/code&gt;&lt;/a&gt; — a pure-Go library for on-demand background process lifecycle. We extracted it from GLIDE because the problem is universal: any CLI tool that needs a shared background server faces the same dance of PID files, health checks, and process identity. No CGO, no vendored C, no &lt;code&gt;x/sys&lt;/code&gt;. Stdlib only.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem Is Not "How to Fork"
&lt;/h2&gt;

&lt;p&gt;Go can't &lt;code&gt;fork()&lt;/code&gt;. The runtime has threads, and forking a multi-threaded process is asking for deadlocks. The typical workaround is re-executing the binary with an environment variable marker. &lt;a href="https://github.com/sevlyar/go-daemon" rel="noopener noreferrer"&gt;sevlyar/go-daemon&lt;/a&gt; does exactly this. &lt;a href="https://github.com/kardianos/service" rel="noopener noreferrer"&gt;kardianos/service&lt;/a&gt; takes a different approach — it wraps your binary to run under a system service manager (systemd, launchd, Windows SCM) and can install unit files via an explicit &lt;code&gt;Install()&lt;/code&gt; call.&lt;/p&gt;

&lt;p&gt;Neither solves our problem. We don't want a system service that lives forever. We want a process that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Starts when the first agent needs it.&lt;/li&gt;
&lt;li&gt;Shares itself with every subsequent agent.&lt;/li&gt;
&lt;li&gt;Dies when the last agent leaves.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The owner of the process lifetime is &lt;strong&gt;demand&lt;/strong&gt;, not an init system.&lt;/p&gt;

&lt;h2&gt;
  
  
  EnsureRunning: The Core Abstraction
&lt;/h2&gt;

&lt;p&gt;The entire library exists so you can write this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EnsureRunning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;"myapp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DataDir&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;".myapp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Args&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"serve"&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;If the daemon is already running, you get the port. If not, the library spawns it, waits for the health check, and gives you the port. If two agents call &lt;code&gt;EnsureRunning&lt;/code&gt; at the exact same millisecond, one wins the startup race and the other gets the same port. No duplicate daemons. Ever.&lt;/p&gt;

&lt;p&gt;The trick is a startup lock file — &lt;code&gt;flock&lt;/code&gt; on Unix, &lt;code&gt;LockFileEx&lt;/code&gt; on Windows — that serializes all startup attempts across processes. The winner spawns the child, passes the PID file lock via &lt;code&gt;ExtraFiles&lt;/code&gt; (so the child inherits ownership with zero gap), and the loser polls until the PID file contains a port.&lt;/p&gt;

&lt;p&gt;This required more thought than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PID File Is the Lock
&lt;/h2&gt;

&lt;p&gt;Most daemon libraries use the PID file as a data file: write the PID, read it later, check if the process is alive with &lt;code&gt;kill(pid, 0)&lt;/code&gt;. This has a classic race: process dies, PID gets recycled to an unrelated process, your library kills someone's database.&lt;/p&gt;

&lt;p&gt;We took a different approach. The PID file is opened with an exclusive &lt;code&gt;flock&lt;/code&gt; (or Windows share-mode lock), and the lock is held for the entire lifetime of the daemon. When the daemon dies — cleanly, via SIGKILL, via OOM — the kernel releases the lock. No timers, no polling, no PID reuse bugs. Identity is a kernel lock, not a number.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pidlock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TryLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pidPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c"&gt;// flock(LOCK_EX|LOCK_NB)&lt;/span&gt;
&lt;span class="c"&gt;// ... write PID data through the lock (Seek+Write+Truncate) ...&lt;/span&gt;
&lt;span class="c"&gt;// ... pass lock.File() to child via ExtraFiles ...&lt;/span&gt;
&lt;span class="c"&gt;// child inherits the lock — zero window where it's released&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Unix, &lt;code&gt;ExtraFiles&lt;/code&gt; passes the locked fd to the child — the lock is never released between parent and child. On Windows, &lt;code&gt;ExtraFiles&lt;/code&gt; is not supported (Go issue #26182), so the parent releases the lock under the startup lock, and the child re-acquires it in &lt;code&gt;Serve&lt;/code&gt; with a retry loop. The startup lock serializes this — no window for a third process to steal it.&lt;/p&gt;

&lt;p&gt;The lock file is never deleted. Deleting a &lt;code&gt;flock&lt;/code&gt;'d file while another process waits on &lt;code&gt;flock()&lt;/code&gt; causes the inode-reuse race — both processes end up locking different inodes at the same path. We learned this the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting Connections Was Wrong
&lt;/h2&gt;

&lt;p&gt;v0.1.0 had connect/disconnect endpoints. Proxy starts, POSTs &lt;code&gt;/daemon/connect&lt;/code&gt;, counter goes up. Proxy stops, POSTs &lt;code&gt;/daemon/disconnect&lt;/code&gt;, counter goes down. When the counter hits zero and &lt;code&gt;IdleTimeout&lt;/code&gt; fires, the daemon exits.&lt;/p&gt;

&lt;p&gt;Sounds clean. Here's what actually happens: a proxy gets SIGKILL'd. A terminal closes. An OOM killer strikes. The disconnect POST never fires. The counter stays at 1 forever. A daemon with &lt;code&gt;IdleTimeout: 5 * time.Minute&lt;/code&gt; sits there consuming RAM until someone manually kills it.&lt;/p&gt;

&lt;p&gt;We patched the counter twice — clamping to prevent negative counts from stray disconnects, and CAS loops for concurrent disconnect races. Both fixed real bugs, but neither touches the actual problem: &lt;strong&gt;counting requires both endpoints to fire, and one of them is called by a dead process.&lt;/strong&gt; The positive count leak is inherent to the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leases: Let the Kernel Count
&lt;/h2&gt;

&lt;p&gt;The fix landed in v0.3.1. Instead of POST/POST, the proxy opens a &lt;code&gt;GET /daemon/attach&lt;/code&gt; request and holds the TCP connection open for its entire lifetime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Proxy               Daemon
  │                    │
  ├── GET /daemon/attach ──► ct.Connect()
  │         │          │
  │    TCP alive       │  count &amp;gt; 0, idle timer blocked
  │         │          │
  ╳ SIGKILL            │
  │                    │
kernel closes socket ──► ct.Disconnect()
                        │
                   count drops to 0 → idle shutdown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The TCP connection &lt;strong&gt;is&lt;/strong&gt; the lease. When the proxy dies — for any reason — the kernel closes the socket, the HTTP handler's &lt;code&gt;r.Context()&lt;/code&gt; gets cancelled, the deferred &lt;code&gt;ct.Disconnect()&lt;/code&gt; fires, and the count drops. No heartbeats. No watchdogs. No stale counts.&lt;/p&gt;

&lt;p&gt;A v0.3.1+ proxy against an older daemon without &lt;code&gt;/daemon/attach&lt;/code&gt; detects 404 and falls back to the old connect/disconnect pair. The reverse (old proxy, new daemon) gets 401 because the token is missing — upgrade the proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bearer Token from the PID File
&lt;/h2&gt;

&lt;p&gt;The daemon listens on &lt;code&gt;127.0.0.1&lt;/code&gt; with a random port, so you might think it's safe. DNS rebinding is a real threat — a malicious page can resolve a domain to &lt;code&gt;127.0.0.1&lt;/code&gt; and attempt requests. The &lt;code&gt;Host&lt;/code&gt; header check (&lt;code&gt;loopbackGuard&lt;/code&gt;, added in v0.1.1) stops this: browsers cannot forge the &lt;code&gt;Host&lt;/code&gt; header, so the rebinding attack fails at the middleware.&lt;/p&gt;

&lt;p&gt;The token protects against a different threat: other local processes that can already reach the port, or any non-browser client. The daemon generates a random token at startup (&lt;code&gt;crypto/rand&lt;/code&gt;) and writes it to the PID file, which has &lt;code&gt;0600&lt;/code&gt; permissions on Unix (on Windows, the &lt;code&gt;DataDir&lt;/code&gt; ACL is the boundary). Every &lt;code&gt;/daemon/*&lt;/code&gt; control-plane endpoint requires &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;. The proxy reads the token from the PID file and sends it automatically. Your application handler (&lt;code&gt;/mcp&lt;/code&gt;, custom routes) is open by default for &lt;code&gt;curl&lt;/code&gt; debugging — set &lt;code&gt;Config.RequireToken = true&lt;/code&gt; to lock it down too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// In Serve():&lt;/span&gt;
&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;rand&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c"&gt;// crypto/rand, Go 1.24+&lt;/span&gt;
&lt;span class="n"&gt;pidData&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;
&lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pidData&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c"&gt;// Middleware:&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;authGuard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// /daemon/* → require Bearer, constant-time compare&lt;/span&gt;
    &lt;span class="c"&gt;// /health → pass through (external probes)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What We Didn't Build
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No process supervision.&lt;/strong&gt; If you need your daemon to restart after a crash, use systemd with &lt;code&gt;Restart=on-failure&lt;/code&gt;. Our library complements kardianos/service — it doesn't replace it. Mixing &lt;code&gt;Restart=always&lt;/code&gt; with idle auto-shutdown creates two supervisors that fight each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No remote access.&lt;/strong&gt; Localhost only. If you need remote dev, that's your application's job, not the daemon library's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No log rotation.&lt;/strong&gt; We write to a log file. When it gets big, you rotate it. If this bothers enough people, we'll add it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No heartbeats.&lt;/strong&gt; The lease model eliminates the need. Heartbeats are the wrong abstraction when the kernel already tells you the connection is dead.&lt;/p&gt;

&lt;p&gt;There is a school of thought that says daemonization is an anti-pattern and every background process should be an OS service. That is right when the requirement is supervision — boot-time presence, automatic restart, root-installed units. Our requirement is the opposite: a process that exists only while agents need it, spawned by tools that cannot &lt;code&gt;sudo&lt;/code&gt;. The two are complementary, which is why this library does not replace kardianos/service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2,651 lines of library code.&lt;/strong&gt; ~5.4K lines of tests. 160+ test functions. 2:1 test-to-code ratio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero dependencies.&lt;/strong&gt; &lt;code&gt;go.mod&lt;/code&gt; has no &lt;code&gt;require&lt;/code&gt; block.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three platforms.&lt;/strong&gt; CI runs build + test + lint + race detector on Linux, macOS, and Windows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;80%+ code coverage&lt;/strong&gt; (85% root package). Every lifecycle feature has an integration test using the helper-process pattern (no external binaries).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No marginal binary cost&lt;/strong&gt; if your binary already imports &lt;code&gt;net/http&lt;/code&gt; (most MCP/CLI tools do). Otherwise you pay for &lt;code&gt;net/http&lt;/code&gt;, not for this library — there are no transitive dependencies.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go get github.com/grpmsoft/daemon
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The README has complete examples for all three modes — &lt;a href="https://github.com/grpmsoft/daemon" rel="noopener noreferrer"&gt;client, server, proxy&lt;/a&gt;. The library is at v0.3.3, pre-1.0. The API evolves fast — we break things when the design demands it, not on a schedule.&lt;/p&gt;

&lt;p&gt;If you're building a CLI tool that needs a shared background server — an MCP server, a language server proxy, a build daemon, a local API cache — this might save you from reinventing the same PID-file-plus-health-check dance that every Go CLI eventually writes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/grpmsoft/daemon" rel="noopener noreferrer"&gt;grpmsoft/daemon&lt;/a&gt; — Pure-Go on-demand process lifecycle for shared background services. MIT license.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>opensource</category>
      <category>daemon</category>
      <category>devtools</category>
    </item>
    <item>
      <title>gogpu/ui v0.1.54: We Built Flutter's Gesture System in Pure Go — Arena, Recognizers, and All</title>
      <dc:creator>Andrey Kolkov</dc:creator>
      <pubDate>Thu, 13 Aug 2026 16:33:34 +0000</pubDate>
      <link>https://dev.to/gogpu/gogpuui-v0154-we-built-flutters-gesture-system-in-pure-go-arena-recognizers-and-all-31bl</link>
      <guid>https://dev.to/gogpu/gogpuui-v0154-we-built-flutters-gesture-system-in-pure-go-arena-recognizers-and-all-31bl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Series: Building Go's GPU Ecosystem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Recent articles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/kolkov/gogpuui-v0121-enterprise-render-pipeline-layer-tree-damage-tracking-0-gpu-idle-5adm"&gt;Enterprise Render Pipeline — Layer Tree, Damage Tracking, 0% GPU Idle&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/kolkov/gogpu-extracting-a-gpu-compositor-in-pure-go-patterns-from-flutter-and-chromium-9ig"&gt;Extracting a GPU Compositor — Patterns from Flutter and Chromium&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/kolkov/we-built-a-pure-go-3d-renderer-then-embedded-it-inside-a-gui-widget-4mo4"&gt;Pure Go 3D Renderer Embedded Inside a GUI Widget&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/kolkov/google-says-go-is-ideal-for-ai-assisted-engineering-weve-been-proving-it-for-a-year-2j7p"&gt;Google Says Go Is Ideal for AI-Assisted Engineering&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Problem: Every Widget Reinvented Click Detection
&lt;/h2&gt;

&lt;p&gt;Our GUI toolkit has 27 widgets — buttons, checkboxes, sliders, text fields, dropdowns, dialogs, tab views, tree views, data tables, docking panels, and more — and each one implemented its own click/drag/selection logic from scratch. Button tracked &lt;code&gt;pressed&lt;/code&gt; state. Slider tracked &lt;code&gt;dragging&lt;/code&gt;. TextField had its own &lt;code&gt;MouseDrag&lt;/code&gt; handler. No code was shared. No disambiguation existed.&lt;/p&gt;

&lt;p&gt;When we wanted to add text selection to TextField (drag to select, double-click to select word, triple-click to select all), we realized the ad-hoc approach couldn't scale. What happens when a TextField is inside a ScrollView? Both want drag events. Who wins?&lt;/p&gt;

&lt;p&gt;Flutter solved this problem in 2017 with the &lt;a href="https://docs.flutter.dev/ui/interactivity/gestures#gesture-disambiguation" rel="noopener noreferrer"&gt;GestureArena&lt;/a&gt;. We decided to port the same architecture to Go.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Built
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Gesture Arena
&lt;/h3&gt;

&lt;p&gt;The arena is the core disambiguation mechanism. When a pointer goes down, all widgets under the pointer register their gesture recognizers in the arena. The recognizers compete:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First to accept wins.&lt;/strong&gt; If a recognizer is confident it detected its gesture (e.g., drag exceeded the slop threshold), it accepts and all others are rejected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Last to not reject wins.&lt;/strong&gt; If everyone else rejects (e.g., no movement for drag), the remaining recognizer wins by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sweep after pointer up.&lt;/strong&gt; If no one accepted, the first member wins as a last resort.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Widget implements gesture.GestureAware&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Widget&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;GestureHitTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="n"&gt;geometry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Point&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;gesture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Recognizer&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="n"&gt;gesture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Recognizer&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clickRec&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;h3&gt;
  
  
  Four Recognizers + VelocityTracker
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Recognizer&lt;/th&gt;
&lt;th&gt;What it detects&lt;/th&gt;
&lt;th&gt;Used by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ClickRecognizer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Single/double/triple click with timing&lt;/td&gt;
&lt;td&gt;Button, Checkbox, Radio&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DragRecognizer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pan, horizontal, vertical drag with velocity&lt;/td&gt;
&lt;td&gt;Slider, SplitView&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LongPressRecognizer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Press-and-hold (500ms, frame-based)&lt;/td&gt;
&lt;td&gt;Future: context menu&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TapAndDragRecognizer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Click counting + drag in one recognizer&lt;/td&gt;
&lt;td&gt;TextField&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;VelocityTracker&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Least-squares velocity for fling detection&lt;/td&gt;
&lt;td&gt;ScrollView momentum&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Per-Device Thresholds
&lt;/h3&gt;

&lt;p&gt;Mouse and touch have different precision. A 1px movement on a mouse is intentional; on a touchscreen it's noise. We use the same constants Flutter validated against millions of users:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Mouse&lt;/th&gt;
&lt;th&gt;Touch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Drag threshold (slop)&lt;/td&gt;
&lt;td&gt;1px&lt;/td&gt;
&lt;td&gt;18px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Double-click timeout&lt;/td&gt;
&lt;td&gt;300ms&lt;/td&gt;
&lt;td&gt;300ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Double-click distance&lt;/td&gt;
&lt;td&gt;4px (Chromium)&lt;/td&gt;
&lt;td&gt;100px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anti-bounce minimum&lt;/td&gt;
&lt;td&gt;40ms&lt;/td&gt;
&lt;td&gt;40ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Unified Pointer Pipeline
&lt;/h3&gt;

&lt;p&gt;We replaced the legacy mouse callback path with a single &lt;code&gt;PointerEvent&lt;/code&gt;-based pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Platform → PointerEvent → HandlePointerEvent
    ├── Gesture Arena (recognizer dispatch for GestureAware widgets)
    └── Derived MouseEvent (existing widgets continue working unchanged)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every pointer event flows through this single path. No dual dispatch. Existing widgets receive &lt;code&gt;MouseEvent&lt;/code&gt; as before — derived from the same &lt;code&gt;PointerEvent&lt;/code&gt; that feeds the arena.&lt;/p&gt;

&lt;h3&gt;
  
  
  Container Widget Hit-Testing
&lt;/h3&gt;

&lt;p&gt;The hardest bug we hit: a Collapsible widget (parent) and a Checkbox (child) both had ClickRecognizers. The parent won the arena and silently dropped clicks meant for the child.&lt;/p&gt;

&lt;p&gt;The fix: &lt;code&gt;GestureHitTest(pos geometry.Point)&lt;/code&gt; receives widget-local coordinates. Container widgets only return recognizers when the pointer is in their interactive region:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Collapsible returns recognizer ONLY for header clicks&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Widget&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;GestureHitTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="n"&gt;geometry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Point&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;gesture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Recognizer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;headerRect&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;geometry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Bounds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Width&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headerHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;headerRect&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="c"&gt;// Content-area click → child widgets handle it&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="n"&gt;gesture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Recognizer&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clickRec&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;
  
  
  TextField: The Full Story
&lt;/h2&gt;

&lt;p&gt;TextField was our proof-of-concept. Before v0.1.54, it could place a cursor on click. That's it. No selection, no word selection, no clipboard.&lt;/p&gt;

&lt;p&gt;Now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Click&lt;/strong&gt; to place cursor (with Shift+Click for range extension)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drag&lt;/strong&gt; to select text character-by-character&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Double-click&lt;/strong&gt; to select word (uses &lt;code&gt;wordBoundsAt()&lt;/code&gt; with Unicode word boundaries)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Triple-click&lt;/strong&gt; to select all text&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Double-click + drag&lt;/strong&gt; for word-by-word selection&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ctrl+C/V/X&lt;/strong&gt; for OS clipboard (Win32, macOS, Linux)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of this through one &lt;code&gt;TapAndDragRecognizer&lt;/code&gt; that provides &lt;code&gt;consecutiveTapCount&lt;/code&gt; on every callback — exactly how Flutter's &lt;code&gt;TextSelectionGestureDetector&lt;/code&gt; works.&lt;/p&gt;

&lt;h2&gt;
  
  
  OS Clipboard
&lt;/h2&gt;

&lt;p&gt;We added &lt;code&gt;widget.ClipboardProvider&lt;/code&gt; — a DI interface (same pattern as our &lt;code&gt;SoundPlayer&lt;/code&gt;) that bridges widget clipboard requests to the platform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// In widget package — no platform imports&lt;/span&gt;
&lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClipboardWrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"copied text"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClipboardRead&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c"&gt;// Desktop layer registers the platform implementation&lt;/span&gt;
&lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RegisterClipboardProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gogpuApp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// Win32/macOS/Linux&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ctrl+C copies to system clipboard. Ctrl+V pastes from it. Works across applications.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signals Integration
&lt;/h2&gt;

&lt;p&gt;Gesture state is exposed as opt-in reactive signals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;dragRecognizer&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;gesture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewDragRecognizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gesture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DragConfig&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;OnDragStart&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;handleStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;gesture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithDraggingSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isDragging&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c"&gt;// Widget binds to signal for reactive redraw&lt;/span&gt;
&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BindToScheduler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isDragging&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bool signals suppress no-op notifications. Point signals fire on every change. Frame-based timers (no goroutines) for long press.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~8,300 LOC&lt;/strong&gt; added (gesture/ package + widget migrations + clipboard + tests)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;124 gesture tests&lt;/strong&gt;, 96% coverage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;20 widgets&lt;/strong&gt; implement &lt;code&gt;GestureAware&lt;/code&gt; with &lt;code&gt;GestureHitTest&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0 goroutines&lt;/strong&gt; in the entire gesture system — single-threaded by design&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 enterprise references studied&lt;/strong&gt;: Flutter (source code verified), Chromium, GTK4, Qt6, Android&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The gesture system is designed for multi-touch from day one. &lt;code&gt;PointerEvent&lt;/code&gt; carries &lt;code&gt;PointerID&lt;/code&gt; and &lt;code&gt;PointerType&lt;/code&gt; (mouse/touch/pen). When Android support lands (&lt;a href="https://github.com/besmpl" rel="noopener noreferrer"&gt;@besmpl&lt;/a&gt; is working on it), pinch-to-zoom and multi-finger gestures will plug into the same arena.&lt;/p&gt;

&lt;p&gt;Context menu (right-click) is the next visible feature. The gesture infrastructure supports any button — &lt;code&gt;ClickRecognizer&lt;/code&gt; just needs a &lt;code&gt;ButtonRight&lt;/code&gt; config.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;gogpu/ui&lt;/strong&gt; is an enterprise GUI toolkit for Go with GPU-accelerated rendering. Zero CGO, 220K+ LOC, 70 packages, 7,800+ tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Help Us Build Go's GUI Future
&lt;/h2&gt;

&lt;p&gt;The gesture system is the kind of infrastructure that needs real-world validation. We tested with our gallery and examples, but the edge cases live in your applications.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it.&lt;/strong&gt; Clone the repo, run the gallery, drag-select some text, double-click a word. If something feels wrong — &lt;a href="https://github.com/gogpu/ui/issues" rel="noopener noreferrer"&gt;file an issue&lt;/a&gt;. If it feels right — even better. Tell us.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/gogpu/ui.git &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;ui
&lt;span class="nv"&gt;GOGPU_GRAPHICS_API&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;vulkan go run ./examples/gallery/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Star the repo.&lt;/strong&gt; It sounds small, but GitHub stars are how Go developers discover projects. We're listed in &lt;a href="https://github.com/avelino/awesome-go" rel="noopener noreferrer"&gt;awesome-go&lt;/a&gt; — every star helps us stay there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spread the word.&lt;/strong&gt; A mention in your team's Slack, a retweet, a discussion with a colleague who's frustrated with GUI options in Go. Most Go developers don't know a pure Go GUI toolkit with GPU rendering exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contribute.&lt;/strong&gt; The gesture system is designed for extensibility. Build a custom recognizer, add a new widget, improve accessibility, test on your hardware. Check &lt;a href="https://github.com/gogpu/ui/blob/main/CONTRIBUTING.md" rel="noopener noreferrer"&gt;CONTRIBUTING.md&lt;/a&gt;. &lt;a href="https://github.com/besmpl" rel="noopener noreferrer"&gt;@besmpl&lt;/a&gt; contributed 20+ PRs across the ecosystem including Android arm64 support — contributors make this project better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Support the project.&lt;/strong&gt; If gogpu saves you from CGO hell or fills a gap in Go's ecosystem, consider &lt;a href="https://opencollective.com/gogpu" rel="noopener noreferrer"&gt;supporting on Open Collective&lt;/a&gt;. Open source is free to use but not free to build.&lt;/p&gt;

&lt;p&gt;The gogpu ecosystem is 1.25M+ lines of pure Go with zero CGO. gogpu/ui alone: 220K+ LOC, 70 packages, 7,800+ tests. Five GPU backends. Three platforms. One goal: make Go the best language for building desktop applications.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://github.com/gogpu/ui" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://github.com/orgs/gogpu/discussions" rel="noopener noreferrer"&gt;Discussions&lt;/a&gt; · &lt;a href="https://github.com/gogpu/ui/releases/tag/v0.1.54" rel="noopener noreferrer"&gt;Release v0.1.54&lt;/a&gt; · &lt;a href="https://github.com/avelino/awesome-go" rel="noopener noreferrer"&gt;awesome-go&lt;/a&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>flutter</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
    <item>
      <title>Google Says Go Is Ideal for AI-Assisted Engineering. We've Been Proving It for a Year.</title>
      <dc:creator>Andrey Kolkov</dc:creator>
      <pubDate>Wed, 12 Aug 2026 08:02:47 +0000</pubDate>
      <link>https://dev.to/kolkov/google-says-go-is-ideal-for-ai-assisted-engineering-weve-been-proving-it-for-a-year-2j7p</link>
      <guid>https://dev.to/kolkov/google-says-go-is-ideal-for-ai-assisted-engineering-weve-been-proving-it-for-a-year-2j7p</guid>
      <description>&lt;p&gt;Yesterday Google published &lt;a href="https://developers.googleblog.com/why-go-is-an-ideal-language-for-ai-assisted-software-engineering/" rel="noopener noreferrer"&gt;"Why Go is an Ideal Language for AI-Assisted Software Engineering"&lt;/a&gt; — arguing that AI shifted the bottleneck from writing code to reviewing it, and Go's integrated platform provides the guardrails AI teammates need.&lt;/p&gt;

&lt;p&gt;We read it and thought: &lt;em&gt;yes, we know.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Since fall 2025 we've been building Pure Go infrastructure that Go was missing — starting with &lt;a href="https://github.com/scigolib/hdf5" rel="noopener noreferrer"&gt;HDF5&lt;/a&gt; and &lt;a href="https://github.com/born-ml/born" rel="noopener noreferrer"&gt;Born ML&lt;/a&gt; (GPU-accelerated machine learning), then launching &lt;a href="https://github.com/gogpu/gogpu" rel="noopener noreferrer"&gt;GoGPU&lt;/a&gt; in December 2025. Today it's 1.25 million lines of code. 15 repositories. Shader compiler, WebGPU implementation, 2D/3D graphics, GUI toolkit, audio engine, multi-process compositor. Zero CGO. All built using &lt;a href="https://dev.to/kolkov/from-vibe-coding-to-agentic-engineering-what-karpathy-got-right-and-whats-missing-62e"&gt;Smart Coding&lt;/a&gt; — our methodology where humans own architecture, specification, and review (70%), while AI handles mechanical implementation (30%), with cumulative knowledge transfer between sessions.&lt;/p&gt;

&lt;p&gt;Here's what Google got right, what we learned the hard way, and what the article doesn't mention.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Google Got Right
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "The bottleneck shifted from writing to reviewing"
&lt;/h3&gt;

&lt;p&gt;This is the central thesis, and it's correct. When AI generates hundreds of lines per session, the limiting factor becomes: &lt;em&gt;can you verify this is correct?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In our workflow, every PR gets claim-by-claim validation. When a contributor submits "fix: correct PDF y-axis transform," we don't just read the diff — we verify the math:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Transform.Then applies the receiver first.
Translate(0,h).Then(Scale(1,-1)) → y' = -(y+h) ← WRONG
Scale(1,-1).Then(Translate(0,h)) → y' = h-y     ← CORRECT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We write the proof in a temp file, run it, confirm the output, &lt;em&gt;then&lt;/em&gt; approve. AI generates the fix. Humans verify the math. Google's thesis in action.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Go's integrated platform enables AI self-correction loops"
&lt;/h3&gt;

&lt;p&gt;Google argues that gofmt, go test, go vet, and the compiler form a feedback loop where AI can iteratively fix its own output. True — and we run this loop on every change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gofmt &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;                    &lt;span class="c"&gt;# format&lt;/span&gt;
go build ./...                &lt;span class="c"&gt;# compile (catches type errors, hallucinated APIs)&lt;/span&gt;
go vet ./...                  &lt;span class="c"&gt;# static analysis&lt;/span&gt;
golangci-lint run &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5m &lt;span class="c"&gt;# 30+ linters&lt;/span&gt;
go &lt;span class="nb"&gt;test&lt;/span&gt; ./...                 &lt;span class="c"&gt;# behavioral verification&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across 1.25M LOC, this catches most AI mistakes before a human ever looks at the code. The compiler is the first reviewer.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Static types = safety net for agentic code"
&lt;/h3&gt;

&lt;p&gt;Google: &lt;em&gt;"If an AI agent attempts to use a non-existent method, pass an incorrect type, or leave a variable uninitialized, the code simply will not compile."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We cross-compile for three platforms on every PR:&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="nv"&gt;GOOS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;windows go build ./...
&lt;span class="nv"&gt;GOOS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;linux   go build ./...
&lt;span class="nv"&gt;GOOS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;darwin  go build ./...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This catches platform-specific hallucinations that single-platform builds miss — an AI might generate a Windows syscall in a Linux file, or reference a macOS framework in cross-platform code.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Single static binary, zero system dependencies"
&lt;/h3&gt;

&lt;p&gt;Our ecosystem proves this at scale. &lt;code&gt;go build&lt;/code&gt; produces a binary that includes a WebGPU implementation (Vulkan/DX12/Metal/GLES/Software backends), a shader compiler (WGSL → SPIR-V/MSL/GLSL/HLSL/DXIL), a 2D graphics engine, and a GUI toolkit. No DLLs, no shared libraries, no runtime dependencies. No C compiler required.&lt;/p&gt;

&lt;p&gt;This is what "batteries-included" means when taken seriously.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Google Didn't Do — and We Did
&lt;/h2&gt;

&lt;p&gt;Google created Go in 2009. They wrote the article about Go being ideal for AI in 2026. In between — 17 years — Go lacked three things every serious platform needs: professional graphics, GPU access, and an ML ecosystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No GPU graphics.&lt;/strong&gt; No GPU-accelerated 2D library. No shader compiler. No WebGPU implementation. No GUI toolkit with native rendering. Java had Swing, then JavaFX. .NET had WPF, then MAUI. Rust got wgpu, then Bevy. Go got Fyne (CGO + OpenGL) and Ebiten (game engine, not a platform).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No GPU compute.&lt;/strong&gt; Python has PyTorch, CUDA, TensorFlow. Rust has wgpu-rs for compute shaders and &lt;a href="https://github.com/tracel-ai/burn" rel="noopener noreferrer"&gt;Burn&lt;/a&gt; for ML. Go had... &lt;code&gt;os/exec&lt;/code&gt; to shell out to Python. A language "ideal for AI-assisted engineering" that couldn't run a GPU kernel without calling C. The irony: Google co-created the &lt;a href="https://www.w3.org/TR/webgpu/" rel="noopener noreferrer"&gt;WebGPU standard&lt;/a&gt; (W3C) and built &lt;a href="https://dawn.googlesource.com/dawn" rel="noopener noreferrer"&gt;Dawn&lt;/a&gt; — the reference WebGPU implementation for Chrome. But they never gave Go access to their own standard. We built the &lt;a href="https://en.wikipedia.org/wiki/WebGPU" rel="noopener noreferrer"&gt;third native WebGPU implementation&lt;/a&gt; in the world — and the only one in Go.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No ML ecosystem.&lt;/strong&gt; Google writes the article about Go and AI, then builds TensorFlow in Python/C++, JAX in Python, and Gemini in... not Go. The language they praise as ideal for AI couldn't train a model.&lt;/p&gt;

&lt;p&gt;Google builds Flutter (Dart), Android UI (Kotlin), Chrome (C++), TensorFlow (Python/C++). They never invested in making Go a first-class platform for graphics, GPU compute, or machine learning.&lt;/p&gt;

&lt;p&gt;We're fixing all three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Graphics:&lt;/strong&gt; Shader compiler ahead of Rust naga on some features (DXIL generator — Rust hasn't shipped theirs). WebGPU implementation — the third in the world after Chrome's Dawn and Firefox's wgpu, and the only one in Go. GUI toolkit listed in &lt;a href="https://github.com/avelino/awesome-go" rel="noopener noreferrer"&gt;awesome-go&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPU compute:&lt;/strong&gt; &lt;a href="https://github.com/born-ml/born" rel="noopener noreferrer"&gt;Born ML&lt;/a&gt; — a production ML framework fully migrated to our Pure Go GPU stack. Trains models on GPU. No Python, no CUDA, no CGO.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The full stack:&lt;/strong&gt; 1.25M lines of Pure Go. From shader compilation to neural network training to GUI rendering — one language, one toolchain, &lt;code&gt;go build&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google's article validates &lt;em&gt;why&lt;/em&gt; Go is the right language. GoGPU and Born ML prove &lt;em&gt;what's possible&lt;/em&gt; when someone actually builds the missing pieces.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Learned That Google Didn't Mention
&lt;/h2&gt;

&lt;h3&gt;
  
  
  AI-generated code needs claim-by-claim validation, not just compilation
&lt;/h3&gt;

&lt;p&gt;Google focuses on the compiler as the safety net. The compiler catches &lt;em&gt;syntax&lt;/em&gt; and &lt;em&gt;type&lt;/em&gt; errors. It does not catch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wrong algorithm&lt;/strong&gt; — code compiles but produces incorrect output&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong architecture&lt;/strong&gt; — code works but cements the wrong design&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong numbers&lt;/strong&gt; — "renderer.go: ~720 → ~400 LOC" when the actual delta is 2,174 → 1,892&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In our dev.to articles, we validate every factual claim against code before publishing. "Flutter's flow/ has ~15K LOC" — we count: actual is ~22K total lines. "ssa.Func has 30+ fields" — we count: actual is 41. "UI has 24 widgets" — we check README: actual is 27.&lt;/p&gt;

&lt;p&gt;The compiler can't do this. Humans must.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enterprise references prevent AI from reinventing wheels badly
&lt;/h3&gt;

&lt;p&gt;Google mentions "cleaner training data" from Go's standardized ecosystem. But they don't address a deeper problem: AI confidently generates &lt;em&gt;plausible-looking architecture&lt;/em&gt; that violates well-established patterns.&lt;/p&gt;

&lt;p&gt;When we needed a GPU compositor, AI would happily generate a god-struct with 200 fields and bidirectional dependencies. Instead, we researched how Flutter (&lt;code&gt;flow/&lt;/code&gt;), Chromium (&lt;code&gt;cc/&lt;/code&gt;), and GTK4 (&lt;code&gt;GSK&lt;/code&gt;) handle compositors. The answer was consistent: unidirectional deps, concrete structs, minimal callback interfaces. Research before code.&lt;/p&gt;

&lt;p&gt;We formalized this as a rule: &lt;strong&gt;every GPU/HAL/sync/barrier change starts with enterprise reference research.&lt;/strong&gt; AI proposes, references validate.&lt;/p&gt;

&lt;p&gt;But references aren't always available. When you're building the third WebGPU implementation in the world, or the first Pure Go DXIL generator, there's no "how Flutter does it" to consult. You're the pioneer. AI can't help here either — it will confidently generate plausible architecture that has never been tested at scale. This is where human judgment, deep domain knowledge, and the willingness to read GPU specs directly become irreplaceable. Google's article assumes references exist. In practice, sometimes you &lt;em&gt;are&lt;/em&gt; the reference.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-agent architecture, not one AI editing everything
&lt;/h3&gt;

&lt;p&gt;Google's article implies a single AI agent working on a single codebase. At our scale (15 repos), the architecture is fundamentally different.&lt;/p&gt;

&lt;p&gt;Each repository has its &lt;strong&gt;own specialized agent&lt;/strong&gt; with domain-specific sub-agents — a shader compiler agent that knows SPIR-V opcodes, a HAL backend agent that understands Vulkan barrier semantics, a GUI toolkit agent that knows widget lifecycle patterns. These agents are tuned for the specific library they serve, with their own project-level instructions, enterprise references, and research protocols.&lt;/p&gt;

&lt;p&gt;The ecosystem-level agent &lt;strong&gt;only coordinates&lt;/strong&gt; — it tracks cascade releases, manages cross-repo issues, and communicates via GitHub. It does NOT implement features in other repos. When it makes a claim ("this API should change in wgpu"), the repo-level agent &lt;strong&gt;independently validates&lt;/strong&gt; that claim through its own deep research. They agree, or they push back with evidence. Very often, the first architectural instinct turns out to be fundamentally wrong — the repo agent catches it because it knows the domain deeply.&lt;/p&gt;

&lt;p&gt;Cross-repo code changes by the coordination agent are &lt;strong&gt;rare exceptions&lt;/strong&gt;, limited to thin interface seams (a new method on &lt;code&gt;gpucontext.DeviceProvider&lt;/code&gt;, a version bump in &lt;code&gt;go.mod&lt;/code&gt;). The real work happens inside each repo's agent session.&lt;/p&gt;

&lt;p&gt;This isn't a preference — it's a &lt;strong&gt;technical necessity&lt;/strong&gt;. No single AI agent can load 1.25M lines of code into its context window. Each repo is 96K-324K lines — already pushing context limits for a single session. The only way to work at ecosystem scale is specialized agents that deeply understand their domain, coordinated by a lightweight orchestrator that trusts but verifies.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;go.work&lt;/code&gt; plays a different but critical role: when all repos are in a shared workspace, &lt;strong&gt;any agent in any repo&lt;/strong&gt; always builds and tests against the current local state of every ecosystem dependency — not the last published version. An agent working on &lt;code&gt;gg&lt;/code&gt; sees unpublished &lt;code&gt;wgpu&lt;/code&gt; changes. An agent fixing a &lt;code&gt;naga&lt;/code&gt; shader bug can immediately verify the fix compiles against the latest &lt;code&gt;wgpu&lt;/code&gt; consumer code. No publish-wait-update cycles. When it's time for a PR or release, the code goes through &lt;strong&gt;multi-stage validation&lt;/strong&gt;: each repo's agent runs its own pre-flight checks (build, test, lint across platforms, feature completeness audit, dependency freshness, documentation scan). The ecosystem agent validates cross-repo consistency. Agents check each other's claims — stubs, incomplete implementations, architectural shortcuts are caught before they reach production. And at the end, &lt;strong&gt;the human reviews and approves&lt;/strong&gt; before anything is published. No release ships without explicit human sign-off.&lt;/p&gt;

&lt;p&gt;The entire process is backed by &lt;strong&gt;persistent knowledge artifacts&lt;/strong&gt;. Every deep investigation produces a research document with enterprise references and source-verified findings. Before implementation, an Architecture Decision Record (ADR) is written — documenting the decision, alternatives rejected, and enterprise patterns adopted. Kanban tasks break the work into trackable units with clear acceptance criteria.&lt;/p&gt;

&lt;p&gt;When it's time to implement, specialized agents receive the full context — the research doc, the ADR, the task description, the relevant enterprise references. They write the code. Then the main repo agent &lt;strong&gt;reviews everything they produced&lt;/strong&gt; — validates claims against code, checks for stubs, verifies test coverage, confirms the implementation matches the ADR. Only after confirming the code meets acceptance criteria does the agent approve the commit and public documentation update. If it doesn't meet the bar, the implementation iterates until it does. This is not a one-shot process — it's a quality ratchet that only moves forward.&lt;/p&gt;

&lt;p&gt;But it's the multi-agent architecture, not go.work, that makes 15-repo AI-assisted development possible at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  CI lint versions drift — and AI can't predict it
&lt;/h3&gt;

&lt;p&gt;A practical problem Google's article doesn't address: golangci-lint &lt;code&gt;latest&lt;/code&gt; in CI means the rules change under you. We've had PRs that pass locally (v2.12.2 on Windows, Go 1.25.5) but fail in CI (same v2.12.2, but Go 1.25.12 on Linux) because staticcheck behavior differs between Go toolchain versions.&lt;/p&gt;

&lt;p&gt;AI generates code that passes &lt;em&gt;your&lt;/em&gt; lint. CI runs &lt;em&gt;different&lt;/em&gt; lint. The fix is pinning versions — but &lt;code&gt;latest&lt;/code&gt; is the default in most GitHub Actions, including golangci-lint's own action.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Scale&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total Pure Go code&lt;/td&gt;
&lt;td&gt;1.25M LOC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repositories&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub stars&lt;/td&gt;
&lt;td&gt;1,200+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Development period&lt;/td&gt;
&lt;td&gt;~1 year (HDF5 + Born ML fall 2025, GoGPU from Dec 2025)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CGO required&lt;/td&gt;
&lt;td&gt;Zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External C/Rust dependencies&lt;/td&gt;
&lt;td&gt;Zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU backends&lt;/td&gt;
&lt;td&gt;5 (Vulkan, DX12, Metal, GLES, Software)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shader targets&lt;/td&gt;
&lt;td&gt;5 (SPIR-V, MSL, GLSL, HLSL, DXIL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platforms&lt;/td&gt;
&lt;td&gt;Windows, macOS, Linux, Browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contributors&lt;/td&gt;
&lt;td&gt;8+&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All built using &lt;a href="https://dev.to/kolkov/from-vibe-coding-to-agentic-engineering-what-karpathy-got-right-and-whats-missing-62e"&gt;Smart Coding&lt;/a&gt; — human architecture + AI implementation + cumulative knowledge — with exactly the Go guardrails Google describes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Ecosystem
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;LOC&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/gogpu/naga" rel="noopener noreferrer"&gt;naga&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;324K&lt;/td&gt;
&lt;td&gt;Shader compiler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/gogpu/gg" rel="noopener noreferrer"&gt;gg&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;312K&lt;/td&gt;
&lt;td&gt;2D graphics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/gogpu/wgpu" rel="noopener noreferrer"&gt;wgpu&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;254K&lt;/td&gt;
&lt;td&gt;WebGPU implementation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/gogpu/ui" rel="noopener noreferrer"&gt;ui&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;211K&lt;/td&gt;
&lt;td&gt;GUI toolkit (27 widgets)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/gogpu/gogpu" rel="noopener noreferrer"&gt;gogpu&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;96K&lt;/td&gt;
&lt;td&gt;App framework&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ 10 more repos&lt;/td&gt;
&lt;td&gt;~53K&lt;/td&gt;
&lt;td&gt;3D, audio, composition, system tray, types&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;Google's article is the theory. GoGPU is the proof. If you're considering Go for an AI-assisted project — we can confirm: the guardrails work. The compiler catches the easy bugs. The toolchain catches the medium bugs. Humans catch the hard bugs. And Go makes all three layers practical at scale.&lt;/p&gt;

&lt;p&gt;But guardrails alone aren't enough. You also need a methodology — &lt;a href="https://dev.to/kolkov/from-vibe-coding-to-agentic-engineering-what-karpathy-got-right-and-whats-missing-62e"&gt;Smart Coding&lt;/a&gt; — where humans own the architecture, AI handles implementation, and cumulative knowledge files ensure each session builds on the last. Go provides the platform. Smart Coding provides the process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Star us on GitHub:&lt;/strong&gt; &lt;a href="https://github.com/gogpu/gogpu" rel="noopener noreferrer"&gt;github.com/gogpu/gogpu&lt;/a&gt; ⭐&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Support continued development:&lt;/strong&gt; &lt;a href="https://opencollective.com/gogpu" rel="noopener noreferrer"&gt;opencollective.com/gogpu&lt;/a&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>ai</category>
      <category>architecture</category>
      <category>opensource</category>
    </item>
    <item>
      <title>GoGPU: Extracting a GPU Compositor in Pure Go — Patterns from Flutter and Chromium</title>
      <dc:creator>Andrey Kolkov</dc:creator>
      <pubDate>Tue, 11 Aug 2026 15:12:05 +0000</pubDate>
      <link>https://dev.to/gogpu/gogpu-extracting-a-gpu-compositor-in-pure-go-patterns-from-flutter-and-chromium-9ig</link>
      <guid>https://dev.to/gogpu/gogpu-extracting-a-gpu-compositor-in-pure-go-patterns-from-flutter-and-chromium-9ig</guid>
      <description>&lt;p&gt;We had a suspicion the compositor was in the wrong package. But it worked, there was only one consumer, and refactoring a GPU compositor is genuinely hard. So we shipped it and moved on.&lt;/p&gt;

&lt;p&gt;Then the 3D renderer arrived. Then the overlays broke. Suspicion turned into certainty — at the worst possible time.&lt;/p&gt;

&lt;p&gt;This is the story of that migration, and what we learned from Flutter, Go stdlib, and Chromium along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup: a Compositor Hidden Inside a Drawing Library
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/gogpu/gogpu" rel="noopener noreferrer"&gt;GoGPU&lt;/a&gt; is a Pure Go GPU ecosystem — 1.25M lines of code, zero CGO. The architecture has three layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gogpu    — app framework (windowing, events, lifecycle)
gg       — 2D graphics (paths, text, GPU SDF, rasterizer)
wgpu     — WebGPU implementation (Vulkan/DX12/Metal/GLES/Software)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When we first built GPU-accelerated rendering for &lt;code&gt;gg&lt;/code&gt;, the compositor logic naturally ended up inside &lt;code&gt;gg&lt;/code&gt;. It was the only consumer. It owned the render target, the blit pipeline, the MSAA resolve, the damage tracking. It decided when to clear vs preserve content.&lt;/p&gt;

&lt;p&gt;This is an &lt;strong&gt;ownership inversion&lt;/strong&gt;. In every enterprise graphics stack — Chromium, Flutter, GTK4 — the &lt;em&gt;compositor&lt;/em&gt; owns the render target and content cache. The &lt;em&gt;drawing library&lt;/em&gt; just records draw commands into whatever surface it's given:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Drawing library&lt;/th&gt;
&lt;th&gt;Compositor&lt;/th&gt;
&lt;th&gt;Who owns the surface?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chromium&lt;/td&gt;
&lt;td&gt;Skia&lt;/td&gt;
&lt;td&gt;cc/ (tile manager)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Compositor&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GTK4&lt;/td&gt;
&lt;td&gt;Render nodes&lt;/td&gt;
&lt;td&gt;GSK&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Compositor&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flutter&lt;/td&gt;
&lt;td&gt;Dart Canvas&lt;/td&gt;
&lt;td&gt;flow/&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Compositor&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GoGPU (before)&lt;/td&gt;
&lt;td&gt;gg&lt;/td&gt;
&lt;td&gt;gogpu&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;gg&lt;/strong&gt; ← wrong&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We suspected this wasn't right. But &lt;code&gt;gg&lt;/code&gt; was the only renderer, it worked, and the refactoring would be complex. So we shipped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Breaking Point: Three Problems, One Root Cause
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Problem 1: Damage rects were single-source
&lt;/h3&gt;

&lt;p&gt;Damage tracking already lived in gogpu — but as a simple passthrough. gg called &lt;code&gt;SetDamageRects()&lt;/code&gt; with its dirty regions, gogpu forwarded them to the platform compositor via &lt;code&gt;VkPresentRegionsKHR&lt;/code&gt;. One source, one consumer, worked fine.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;g3d&lt;/code&gt; arrived. A 3D scene rotating at 60fps sends full-viewport damage. But &lt;code&gt;SetDamageRects&lt;/code&gt; only accepted one set of rects — from gg. When gg rendered a HUD overlay on top of a g3d scene, only the HUD's damage was reported. The g3d viewport outside the HUD? Stale frames on Wayland compositors. Confirmed by @porjo on Asahi Linux (g3d#22) — visible mosaic artifacts because Mesa's &lt;code&gt;wl_surface_damage_buffer&lt;/code&gt; only updated the HUD area.&lt;/p&gt;

&lt;p&gt;So we redesigned the damage system from scratch. &lt;code&gt;SetDamageRects&lt;/code&gt; (single source, simple passthrough) was replaced with &lt;code&gt;RegisterDamageSource&lt;/code&gt; — each renderer registers as a named source, reports its own damage per frame, and gogpu unions all sources at present time. Chromium's &lt;code&gt;DamageTracker&lt;/code&gt; uses the same union pattern. That was ADR-065 — not a refactor, a reimplementation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 2: Two overlays, two libraries
&lt;/h3&gt;

&lt;p&gt;We had &lt;strong&gt;two independent debug overlay implementations&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;gg&lt;/strong&gt; had a damage overlay (&lt;code&gt;damage_debug.go&lt;/code&gt;) — green flash-and-fade on dirty regions (&lt;code&gt;GOGPU_DEBUG_DAMAGE&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ui&lt;/strong&gt; had a dirty widget overlay (&lt;code&gt;debug_dirty.go&lt;/code&gt;) — cyan rectangles on repainted widgets (&lt;code&gt;GOGPU_DEBUG_DIRTY&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both worked fine in isolation. Each had its own env var, its own rendering logic, its own fade effects. And neither knew about the other. A g3d-only app had &lt;strong&gt;zero debug overlays&lt;/strong&gt; — no damage visualization, no FPS counter, nothing.&lt;/p&gt;

&lt;p&gt;The fix: move overlays to the compositor level. A g3d-only app without gg still needs overlays. A multi-renderer app (gg + g3d) needs a unified overlay that sees damage from all sources. That was ADR-066 — a pluggable &lt;code&gt;DebugOverlay&lt;/code&gt; system at the compositor level, with built-in damage and FPS overlays.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 3: The move that broke everything
&lt;/h3&gt;

&lt;p&gt;ADR-065 and ADR-066 shipped together in v0.51.0. Damage sources worked. Overlays worked — until the content was idle and the overlay had a 400ms fade animation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Overlay fade: NeedsAnimationFrame() → RequestRedraw()
2. Next frame: gg says "nothing dirty" → no GPU commands
3. gg owns the render target → no commands = frame skipped
4. Overlay never draws → animation freezes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The compositor (gogpu) couldn't render overlays independently because &lt;strong&gt;gg owned the surface&lt;/strong&gt;. Without gg's cooperation, the compositor couldn't even decide whether to clear or preserve the render target. The Vulkan spec says acquired swapchain images have UNDEFINED content — so without gg drawing, the overlay rendered on garbage.&lt;/p&gt;

&lt;p&gt;Three problems, one root cause: &lt;strong&gt;gg owned what the compositor should own.&lt;/strong&gt; The shortcut that "worked fine" for one consumer became a blocking architectural problem when the second and third consumers appeared. Every enterprise reference confirmed what we'd only suspected — the compositor must own the render target, not the drawing library.&lt;/p&gt;

&lt;p&gt;Delaying further would have meant building workarounds. Each workaround would cement the wrong architecture deeper. So we stopped delaying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1: Moving the Compositor Out of gg (ADR-067)
&lt;/h2&gt;

&lt;p&gt;The first step was getting the compositor code out of &lt;code&gt;gg&lt;/code&gt; and into &lt;code&gt;gogpu&lt;/code&gt; where it belongs.&lt;/p&gt;

&lt;p&gt;We introduced a &lt;strong&gt;compositor-owned composition texture&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before:
  swapchain acquired → gg renders directly → present
  (gg decides Clear/Load, gg owns MSAA resolve)

After:
  swapchain acquired → gogpu clears composition texture
  → gg/g3d render INTO composition texture (they don't know)
  → gogpu draws overlays on top
  → gogpu blits composition texture → swapchain → present
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key insight: content renderers don't need to know about the compositor. &lt;code&gt;Context.SurfaceView()&lt;/code&gt; returns the composition texture view instead of the swapchain view. From &lt;code&gt;gg&lt;/code&gt;'s perspective, nothing changed — it renders to a &lt;code&gt;TextureView&lt;/code&gt;, same as before.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Overlay-only frames became first-class.&lt;/strong&gt; When content is idle but an overlay is animating, the compositor uses &lt;code&gt;LoadOpLoad&lt;/code&gt; (preserve) instead of &lt;code&gt;LoadOpClear&lt;/code&gt;, draws the overlay on the preserved content, and presents. No cooperation from &lt;code&gt;gg&lt;/code&gt; needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2: Extracting internal/compositor/ (ADR-069)
&lt;/h2&gt;

&lt;p&gt;After moving compositor logic into &lt;code&gt;gogpu&lt;/code&gt;, we had ~2,500 LOC of compositor code mixed with ~4,000 LOC of application framework in the same package. Time for the second step: extract it into &lt;code&gt;internal/compositor/&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Research first
&lt;/h3&gt;

&lt;p&gt;Before writing extraction code, we studied three references:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flutter's &lt;code&gt;flow/&lt;/code&gt; (~22K lines)&lt;/strong&gt; — concrete structs, unidirectional deps (&lt;code&gt;shell/&lt;/code&gt; → &lt;code&gt;flow/&lt;/code&gt;, never reverse), GPU resources borrowed per frame through a callback. Closest to our situation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Go stdlib &lt;code&gt;ssa.Func&lt;/code&gt; (41 fields)&lt;/strong&gt; — the largest &lt;code&gt;internal/&lt;/code&gt; split in Go. Struct ownership + &lt;code&gt;Frontend&lt;/code&gt; callback interface. Parent creates the struct, calls methods. Internal package calls back through a minimal interface when it needs the parent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chromium &lt;code&gt;cc/&lt;/code&gt; (~190K lines)&lt;/strong&gt; — interface-heavy dependency injection with &lt;code&gt;*Client&lt;/code&gt;/&lt;code&gt;*Delegate&lt;/code&gt; abstract classes everywhere. A C++ necessity, over-engineered for Go &lt;code&gt;internal/&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What we actually built
&lt;/h3&gt;

&lt;p&gt;The compositor isn't a single god-struct. It's a set of focused types that the root package composes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gogpu/ (root package, renderer.go)
│
├── RenderTarget {
│     damageSources  []*compositor.DamageSource
│     blitPipeline    compositor.BlitPipeline
│     compositeState  compositor.CompositeState
│   }
│
└── internal/compositor/
    ├── blit.go            — BlitResources, BlitDrawRecorder interface
    ├── blit_pipeline.go   — BlitPipeline, CompositeState
    ├── damage_source.go   — DamageSource, per-renderer damage tracking
    ├── damage_scissor.go  — pure geometry: rect union, scissor clipping
    ├── damage_overlay.go  — DamageDebugOverlay (damage rect visualization)
    ├── fps_overlay.go     — FPSDebugOverlay (frame rate counter)
    ├── overlay_pipeline.go — shared GPU pipeline for overlays
    └── shader.go          — WGSL shader sources
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Content renderers (gg, g3d) implement one interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;BlitDrawRecorder&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;RecordBlitDraws&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pass&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;wgpu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RenderPassEncoder&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;The compositor owns the render pass lifecycle. Renderers just record their draws.&lt;/p&gt;

&lt;h3&gt;
  
  
  The numbers
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;renderer.go&lt;/code&gt;: 2,174 → 1,892 lines (-282 LOC of compositor logic removed)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;internal/compositor/&lt;/code&gt;: 2,357 LOC (self-contained subsystem)&lt;/li&gt;
&lt;li&gt;Root package wiring: 194 LOC (thin delegation)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Zero public API change.&lt;/strong&gt; All &lt;code&gt;App&lt;/code&gt;, &lt;code&gt;Context&lt;/code&gt;, &lt;code&gt;RenderTarget&lt;/code&gt; types stayed in the root package. &lt;code&gt;RegisterDamageSource()&lt;/code&gt; return type narrowed to &lt;code&gt;gpucontext.DamageReporter&lt;/code&gt; interface — which external consumers already used. No breaking change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules That Emerged
&lt;/h2&gt;

&lt;p&gt;These are now enforced in CI:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. No type aliases for migrated types.&lt;/strong&gt; &lt;code&gt;type X = internal.X&lt;/code&gt; is for transitional migration only. We changed return types to interfaces instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Call internal directly.&lt;/strong&gt; No unexported wrapper functions around &lt;code&gt;internal/&lt;/code&gt; calls. At call sites: &lt;code&gt;r.blitPipeline.Init(device, format, shader)&lt;/code&gt;, not an unexported &lt;code&gt;initBlitPipeline()&lt;/code&gt; wrapper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Unidirectional deps, enforced in CI:&lt;/strong&gt;&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Compositor dependency direction&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;if grep -r '"github.com/gogpu/gogpu"' internal/compositor/; then&lt;/span&gt;
      &lt;span class="s"&gt;echo "compositor must not import root package"&lt;/span&gt;
      &lt;span class="s"&gt;exit 1&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Performance is not a reason to avoid extraction.&lt;/strong&gt; We benchmarked interface calls: ~1.4-3.0ns vs ~1.3ns for direct calls. At 10 calls/frame × 60fps = ~60ns per frame vs ~16.7ms budget. &lt;strong&gt;0.0005%&lt;/strong&gt; overhead. Go 1.20+ devirtualizes most of these anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"We'll fix it later" has a deadline you don't control.&lt;/strong&gt; We suspected the compositor was in the wrong place for months. The deadline to fix it wasn't set by us — it was set by the overlay freeze bug and the 3D renderer needing damage tracking. Under pressure, every refactoring is harder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enterprise references agree on ownership.&lt;/strong&gt; We checked Chromium, Flutter, and GTK4. In &lt;em&gt;all three&lt;/em&gt;, the compositor owns the surface and content cache. The drawing library just draws. This isn't a coincidence — it's the architecture that survives multi-renderer apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not everything needs a god struct.&lt;/strong&gt; The Go stdlib &lt;code&gt;ssa.Func&lt;/code&gt; pattern (single struct, 40+ fields, callback interface) is powerful but not universal. Our compositor worked better as a set of focused types (3-8 fields each) that the root package composes. Each type has a clear responsibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;internal/&lt;/code&gt; is underrated.&lt;/strong&gt; Most Go projects either put everything in one package or create deep hierarchies. &lt;code&gt;internal/&lt;/code&gt; gives you encapsulation without the API commitment. The compositor is free to change its struct layout without breaking external consumers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Ecosystem
&lt;/h2&gt;

&lt;p&gt;GoGPU is 1.25M lines of Pure Go — a full GPU ecosystem, not just one library:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;LOC&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/gogpu/naga" rel="noopener noreferrer"&gt;naga&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;324K&lt;/td&gt;
&lt;td&gt;Shader compiler (WGSL → SPIR-V/MSL/GLSL/HLSL/DXIL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/gogpu/gg" rel="noopener noreferrer"&gt;gg&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;312K&lt;/td&gt;
&lt;td&gt;2D graphics (Skia-class rasterizer, GPU SDF, ClearType)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/gogpu/wgpu" rel="noopener noreferrer"&gt;wgpu&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;254K&lt;/td&gt;
&lt;td&gt;WebGPU (Vulkan/DX12/Metal/GLES/Software/Browser)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/gogpu/ui" rel="noopener noreferrer"&gt;ui&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;211K&lt;/td&gt;
&lt;td&gt;GUI toolkit (27 widgets, 4 design systems)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/gogpu/gogpu" rel="noopener noreferrer"&gt;gogpu&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;96K&lt;/td&gt;
&lt;td&gt;App framework + compositor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/gogpu/g3d" rel="noopener noreferrer"&gt;g3d&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;14K&lt;/td&gt;
&lt;td&gt;3D rendering (scene graph, PBR, forward renderer)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/gogpu/compose" rel="noopener noreferrer"&gt;compose&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;10K&lt;/td&gt;
&lt;td&gt;Multi-process composition (Unix socket, LZ4, hot-plug)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/gogpu/systray" rel="noopener noreferrer"&gt;systray&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;8K&lt;/td&gt;
&lt;td&gt;System tray (Win32/macOS/Linux, zero CGO)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/gogpu/audio" rel="noopener noreferrer"&gt;audio&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;3.5K&lt;/td&gt;
&lt;td&gt;Audio engine (WASAPI, WAV, Mixer)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zero CGO. &lt;code&gt;go build&lt;/code&gt; and it works. Windows, macOS, Linux, Browser.&lt;/p&gt;

&lt;p&gt;1,200+ GitHub stars. Listed in &lt;a href="https://github.com/avelino/awesome-go" rel="noopener noreferrer"&gt;awesome-go&lt;/a&gt;. Used by &lt;a href="https://github.com/born-ml/born" rel="noopener noreferrer"&gt;Born ML&lt;/a&gt; for GPU compute, &lt;a href="https://github.com/darkliquid/ironwail-go" rel="noopener noreferrer"&gt;ironwail-go&lt;/a&gt; for Quake 1 rendering.&lt;/p&gt;




&lt;p&gt;The compositor migration landed across three releases: v0.51.0 (&lt;a href="https://github.com/gogpu/gogpu/commit/5651dee" rel="noopener noreferrer"&gt;ADR-065&lt;/a&gt; + &lt;a href="https://github.com/gogpu/gogpu/commit/5651dee" rel="noopener noreferrer"&gt;ADR-066&lt;/a&gt;, damage source reimplementation + pluggable overlays), v0.52.0 (&lt;a href="https://github.com/gogpu/gogpu/commit/458f232" rel="noopener noreferrer"&gt;ADR-067&lt;/a&gt;, compositor-owned render target), and v0.52.1 (&lt;a href="https://github.com/gogpu/gogpu/commit/4beb54f" rel="noopener noreferrer"&gt;ADR-069&lt;/a&gt;, package extraction).&lt;/p&gt;

&lt;p&gt;If your drawing library owns compositor state and you only have one consumer — fix it now. The second consumer always arrives sooner than you expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It, Break It, Tell Us
&lt;/h2&gt;

&lt;p&gt;GoGPU is pre-v1.0 — this is the best time to influence the API before it freezes. We need your help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test on your hardware.&lt;/strong&gt; We develop on Intel Iris Xe (Windows) and Mesa llvmpipe (Linux). AMD, NVIDIA, Apple Silicon, Adreno — every GPU we haven't tested is a bug we haven't found. Run the examples, file issues.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contribute.&lt;/strong&gt; PRs welcome — from typo fixes to new backends. We review everything. Check &lt;a href="https://github.com/gogpu/gogpu/labels/good%20first%20issue" rel="noopener noreferrer"&gt;good first issues&lt;/a&gt; across the org.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write about it.&lt;/strong&gt; Tutorials, reviews, comparisons — in any language. &lt;a href="https://www.root.cz/autori/pavel-tisnovsky/" rel="noopener noreferrer"&gt;Pavel Tišnovský&lt;/a&gt; wrote three parts on root.cz (Czech), covering gg 2D graphics and gogpu windowing with 12 interactive examples. If you write something, let us know — we'll link it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spread the word.&lt;/strong&gt; Star us, mention us in your talks, recommend us when someone asks "how do I do GPU in Go?"&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Support the Project
&lt;/h2&gt;

&lt;p&gt;GoGPU is built by a small team in their spare time. If you find it useful, consider supporting continued development:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://opencollective.com/gogpu" rel="noopener noreferrer"&gt;Support GoGPU on Open Collective&lt;/a&gt;&lt;/strong&gt; — every contribution helps us spend more time on the ecosystem instead of client work.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Star us on GitHub:&lt;/strong&gt; &lt;a href="https://github.com/gogpu/gogpu" rel="noopener noreferrer"&gt;github.com/gogpu/gogpu&lt;/a&gt; ⭐&lt;/p&gt;

</description>
      <category>go</category>
      <category>gpu</category>
      <category>architecture</category>
      <category>graphics</category>
    </item>
    <item>
      <title>We Built a Pure Go 3D Renderer — Then Embedded It Inside a GUI Widget</title>
      <dc:creator>Andrey Kolkov</dc:creator>
      <pubDate>Sun, 02 Aug 2026 21:33:11 +0000</pubDate>
      <link>https://dev.to/gogpu/we-built-a-pure-go-3d-renderer-then-embedded-it-inside-a-gui-widget-4mo4</link>
      <guid>https://dev.to/gogpu/we-built-a-pure-go-3d-renderer-then-embedded-it-inside-a-gui-widget-4mo4</guid>
      <description>&lt;p&gt;Go has had a 2D graphics story for a while now. But 3D? The options have been either CGO wrappers around OpenGL, abandoned projects from 2019, or "use Ebiten and write your own engine."&lt;/p&gt;

&lt;p&gt;We decided to build the missing piece: &lt;strong&gt;g3d&lt;/strong&gt; — a Pure Go 3D rendering library with scene graph, PBR materials, and forward rendering pipeline. Zero CGO, all five GPU backends (Vulkan, Metal, DX12, GLES, Software), 374 tests, built on our &lt;a href="https://github.com/gogpu/wgpu" rel="noopener noreferrer"&gt;WebGPU implementation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But a 3D renderer that only works standalone is half the story. The real question is: &lt;strong&gt;how does it compose with UI?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This post covers the two integration patterns we shipped in v0.1.4 — patterns that every enterprise 3D application uses, from Unity to Blender to CAD tools. And how we filed eight cross-repo issues across five repositories to make it work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 20-Line Rotating Cube
&lt;/h2&gt;

&lt;p&gt;Before we get to integration — here's what g3d looks like standalone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;scene&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g3d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewScene&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;scene&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetBackground&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g3d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RGB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;sun&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g3d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewDirectionalLight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;g3d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithLightColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g3d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;White&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;g3d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithLightIntensity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;scene&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sun&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LightNode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="n"&gt;cube&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g3d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewMesh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;g3d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewBoxGeometry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;g3d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewStandardMaterial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;g3d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g3d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RGB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;g3d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithRoughness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;scene&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cube&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MeshNode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="n"&gt;camera&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g3d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewPerspectiveCamera&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;800.0&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;600.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;camera&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CameraNode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetPosition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g3d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Vec3&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Y&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Z&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scene graph, PBR materials, directional lighting — all CPU data structures. No GPU device needed until you render. The API is inspired by Three.js, but idiomatic Go: interfaces + functional options, not OOP inheritance chains.&lt;/p&gt;

&lt;p&gt;The renderer creates itself lazily on first frame:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OnDraw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dc&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;gogpu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;renderer&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g3d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRenderer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GPUContextProvider&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;renderer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;camera&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SurfaceView&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;That's it. Rotating PBR cube on Vulkan. Single binary, zero CGO, &lt;code&gt;go build &amp;amp;&amp;amp; ./app&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Challenge: 3D + UI
&lt;/h2&gt;

&lt;p&gt;A standalone 3D window is a demo. A 3D viewport inside a GUI application — that's a product. Think about what real 3D applications look like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Game:&lt;/strong&gt; fullscreen 3D world with health bar, minimap, crosshair overlaid&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CAD tool:&lt;/strong&gt; 3D model viewer with toolbar, property panel, status bar around it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IDE:&lt;/strong&gt; 3D preview panel embedded alongside code editor and file tree&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are two fundamentally different composition patterns:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;3D Renders To&lt;/th&gt;
&lt;th&gt;UI Renders To&lt;/th&gt;
&lt;th&gt;Who Owns the Surface&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fullscreen overlay&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Swapchain directly&lt;/td&gt;
&lt;td&gt;Same swapchain (on top)&lt;/td&gt;
&lt;td&gt;Both, sequentially&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Embedded widget&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Offscreen texture&lt;/td&gt;
&lt;td&gt;Swapchain (compositor)&lt;/td&gt;
&lt;td&gt;UI framework&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We implemented both. And both required solving problems that no Go project had solved before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1: Fullscreen 3D + 2D Overlay
&lt;/h2&gt;

&lt;p&gt;This is the game/CAD pattern. g3d renders the 3D scene to fill the entire window, then gg (our 2D graphics library) draws HUD elements on top: title, FPS counter, crosshair, status bar.&lt;/p&gt;

&lt;p&gt;The enterprise pattern is well-established — we researched seven engines:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;How They Do It&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bevy + egui&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Separate render pass, &lt;code&gt;LoadOp::Load&lt;/code&gt;, &lt;code&gt;depth_stencil: None&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dear ImGui&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Separate render pass, &lt;code&gt;DepthEnable=false&lt;/code&gt;, alpha blending&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Unity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Screen Space Overlay canvas, rendered after everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Godot&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;CanvasLayer, separate from 3D viewport&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Three.js&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;clearDepth()&lt;/code&gt; between 3D and HUD render calls&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The universal answer: &lt;strong&gt;two render passes on the same surface&lt;/strong&gt;. First pass clears and renders 3D with depth testing. Second pass loads the existing content and renders 2D with alpha blending, no depth.&lt;/p&gt;

&lt;p&gt;In g3d + gogpu + gg, this looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OnDraw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dc&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;gogpu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// Pass 1: g3d renders 3D scene (LoadOp::Clear + depth)&lt;/span&gt;
    &lt;span class="n"&gt;renderer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;camera&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SurfaceView&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="c"&gt;// Bridge: tell gogpu the surface has content&lt;/span&gt;
    &lt;span class="n"&gt;dc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MarkExternalContent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c"&gt;// Pass 2: gg renders 2D HUD (LoadOp::Load + alpha blend)&lt;/span&gt;
    &lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Draw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cc&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;gg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;drawHUD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RenderTarget&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;code&gt;MarkExternalContent()&lt;/code&gt; is the key API. It tells the gogpu framework: "someone already rendered to this surface — don't clear it." Without this call, gg's render pass would wipe the 3D scene with &lt;code&gt;LoadOp::Clear&lt;/code&gt;. With it, gg uses &lt;code&gt;LoadOp::Load&lt;/code&gt; and draws transparently on top.&lt;/p&gt;

&lt;p&gt;This is the same concept as Qt's &lt;code&gt;beginExternal()&lt;/code&gt;/&lt;code&gt;endExternal()&lt;/code&gt;, Flutter's &lt;code&gt;InlinePassContext&lt;/code&gt;, and Unity's &lt;code&gt;Camera.DontClear&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Result
&lt;/h3&gt;

&lt;p&gt;A rotating PBR cube at 60 FPS with a full HUD overlay — title, live FPS counter, animated crosshair, status bar with backend info. All Pure Go, all on the same swapchain, zero copies.&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="nv"&gt;GOGPU_GRAPHICS_API&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;vulkan go run ./examples/fullscreen-overlay/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pattern 2: 3D Inside a GUI Widget
&lt;/h2&gt;

&lt;p&gt;This is the CAD/IDE pattern. The 3D viewport is one widget among many — surrounded by buttons, text, panels. The UI framework owns the window; g3d renders into an offscreen GPU texture; the compositor blits it into the widget tree.&lt;/p&gt;

&lt;p&gt;This is how Qt's &lt;code&gt;QRhiWidget&lt;/code&gt;, Unity's &lt;code&gt;RenderTexture&lt;/code&gt;, and Godot's &lt;code&gt;SubViewport&lt;/code&gt; work.&lt;/p&gt;

&lt;p&gt;In g3d + gogpu/ui:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;vp&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;gpuview&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;gpuview&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;400&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;gpuview&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Continuous&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;gpuview&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OnRender&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt; &lt;span class="n"&gt;gpucontext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TextureView&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;wgpuView&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="n"&gt;wgpu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TextureView&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pointer&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;renderer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;camera&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wgpuView&lt;/span&gt;&lt;span class="p"&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;The &lt;code&gt;gpuview.Widget&lt;/code&gt; (formerly Viewport3D, renamed for universality) handles the GPU texture lifecycle. It creates an offscreen texture, passes it to your &lt;code&gt;OnRender&lt;/code&gt; callback, and hands the result to the Layer Tree compositor for blitting into the final frame.&lt;/p&gt;

&lt;p&gt;Your 3D renderer doesn't know or care that it's inside a widget. It receives a &lt;code&gt;TextureView&lt;/code&gt; and renders to it — same API as fullscreen rendering.&lt;/p&gt;

&lt;p&gt;The UI layout is standard gogpu/ui:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;uiApp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;primitives&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;primitives&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"g3d GPUView"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FontSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;22&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Bold&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;vp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c"&gt;// 3D viewport widget&lt;/span&gt;
    &lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TextOpt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Pause / Resume"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OnClick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;paused&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;paused&lt;/span&gt; &lt;span class="p"&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;.&lt;/span&gt;&lt;span class="n"&gt;Padding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;28&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Gap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;14&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rounded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ShadowLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&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;Material 3 themed card with a 3D viewport, title, and control buttons. The 3D content renders at GPU speed inside the widget while the rest of the UI renders through the standard compositor pipeline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go run ./examples/viewport3d/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Eight Issues Across Five Repos
&lt;/h2&gt;

&lt;p&gt;Getting both patterns working wasn't just API design — it was a debugging marathon across five repositories (g3d, gg, gogpu, ui, wgpu) and eight issues. Here are the three hardest:&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 1: MSAA Resolve Overwrites External Content
&lt;/h3&gt;

&lt;p&gt;When gg renders its 2D overlay, it uses 4x MSAA for antialiased edges. The MSAA render pass writes to an intermediate texture, then resolves to the swapchain. The resolve is a full overwrite — it doesn't care what was on the swapchain before.&lt;/p&gt;

&lt;p&gt;So even with &lt;code&gt;LoadOp::Load&lt;/code&gt;, the MSAA intermediate texture was empty (it never contained the 3D scene), and the resolve wrote &lt;code&gt;empty + HUD&lt;/code&gt; to the swapchain. 3D content gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; gg v0.50.9 implemented Strategy C from Skia's playbook — render the overlay into a separate offscreen MSAA texture, resolve to a 1x intermediate with alpha, then alpha-blend composite onto the swapchain via textured quad. The 3D content survives because the swapchain is never a resolve target.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 2: GPU Buffer Use-After-Free
&lt;/h3&gt;

&lt;p&gt;g3d's renderer created uniform buffers per frame with &lt;code&gt;MappedAtCreation&lt;/code&gt;, used them in a render pass, submitted the command buffer, and immediately released them via &lt;code&gt;defer&lt;/code&gt;. But &lt;code&gt;queue.Submit()&lt;/code&gt; is asynchronous — the GPU was still reading the buffers when Go freed them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;command buffer at index 0 references released buffer "g3d_frame_uniforms"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Replaced per-frame allocation with persistent uniform buffers updated via &lt;code&gt;queue.WriteBuffer()&lt;/code&gt;. Geometry buffers cached by identity. Bind groups deferred-released at the start of the next frame. This is the same pattern gg uses internally — zero allocations in the hot path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 3: Layer Tree Missing External Texture Node
&lt;/h3&gt;

&lt;p&gt;The gpuview widget created its texture, fired &lt;code&gt;OnRender&lt;/code&gt;, and g3d rendered into it correctly. But the widget was invisible. The Layer Tree compositor had &lt;code&gt;ExternalTextureLayer&lt;/code&gt; support in its rendering code — but nobody created those nodes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;buildBoundaryLayer()&lt;/code&gt; only created &lt;code&gt;PictureLayer&lt;/code&gt; nodes. Widgets with external GPU textures needed a parallel &lt;code&gt;ExternalTextureLayer&lt;/code&gt; node so the compositor would blit their content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; ui v0.1.49 added &lt;code&gt;externalTextureWidget&lt;/code&gt; interface detection in the Layer Tree builder. Widgets that provide &lt;code&gt;Texture()&lt;/code&gt; and &lt;code&gt;ViewportSize()&lt;/code&gt; automatically get an &lt;code&gt;ExternalTextureLayer&lt;/code&gt; alongside their &lt;code&gt;PictureLayer&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared Command Encoder: The Third Way
&lt;/h2&gt;

&lt;p&gt;Sometimes you don't want two separate &lt;code&gt;queue.Submit()&lt;/code&gt; calls. g3d v0.1.3 added &lt;code&gt;RenderTo()&lt;/code&gt; — record g3d render passes into a caller-owned command encoder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateCommandEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// g3d records its render pass&lt;/span&gt;
&lt;span class="n"&gt;renderer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RenderTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;camera&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;targetView&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// Other renderers record their passes&lt;/span&gt;
&lt;span class="n"&gt;overlayRenderer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RecordTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoder&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="c"&gt;// One submit for the entire frame&lt;/span&gt;
&lt;span class="n"&gt;commands&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Finish&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;commands&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This eliminates the multi-submit overhead and gives you precise control over render pass ordering. It's the pattern gogpu v0.48.4 uses internally via &lt;code&gt;dc.CommandEncoder()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;Here's how g3d fits into the gogpu ecosystem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your Application
    ├── gogpu (window + GPU device)
    ├── g3d  (3D scene → render passes)
    ├── gg   (2D graphics → render passes)
    └── ui   (widget tree → compositor → render passes)
              └── gpuview widget ← g3d renders here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;g3d depends &lt;strong&gt;down&lt;/strong&gt; only — on wgpu and gpucontext. Never on gogpu, gg, or ui. This means you can use g3d in any context: with gogpu, with your own windowing, or headless for testing.&lt;/p&gt;

&lt;p&gt;The shared GPU device comes through &lt;code&gt;gpucontext.DeviceProvider&lt;/code&gt; — the same &lt;code&gt;database/sql&lt;/code&gt;-style interface pattern that gg, ui, and Born ML use. One device, shared across all renderers, zero-copy resource sharing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Ecosystem Is Accelerating
&lt;/h2&gt;

&lt;p&gt;g3d doesn't exist in isolation. The gogpu ecosystem is growing in directions that directly benefit 3D applications:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero-CGO Android.&lt;/strong&gt; @besmpl proved that &lt;code&gt;CGO_ENABLED=0 -buildmode=c-shared&lt;/code&gt; works on Android arm64 via &lt;code&gt;//go:nativeexport&lt;/code&gt; — a Go toolchain patch that eliminates the last CGO dependency for mobile GPU apps. The &lt;a href="https://github.com/besmpl/go-android-cgo-free-prototype" rel="noopener noreferrer"&gt;prototype&lt;/a&gt; runs on API 30 emulator. Once upstream, g3d will run on Android with the same &lt;code&gt;go build&lt;/code&gt; simplicity as desktop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pure Go Race Detector.&lt;/strong&gt; The GoGPU ecosystem can't use &lt;code&gt;go test -race&lt;/code&gt; today because GPU backends require &lt;code&gt;CGO_ENABLED=0&lt;/code&gt;. We're &lt;a href="https://github.com/golang/go/issues/76786" rel="noopener noreferrer"&gt;contributing to a Pure Go race detector&lt;/a&gt; that achieves read-path parity with TSAN (0.985x on Apple M1). @besmpl delivered 20 optimization commits in 5 days. When merged, every GPU-accelerated Go project gets race detection for free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser/WASM.&lt;/strong&gt; wgpu's triple-backend architecture (ADR-038) means g3d will run in the browser via WebGPU — same Go code, &lt;code&gt;GOOS=js GOARCH=wasm go build&lt;/code&gt;. The browser backend is already shipping in wgpu for 2D; 3D follows the same path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Composition library.&lt;/strong&gt; &lt;a href="https://github.com/gogpu/compose" rel="noopener noreferrer"&gt;gogpu/compose&lt;/a&gt; enables multi-process GPU composition — think VS Code's renderer process architecture, but for Go. A g3d viewport in one process, a 2D editor in another, composited via Unix socket + LZ4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Community tutorials.&lt;/strong&gt; Pavel Tišnovský (2,185+ articles on root.cz, Czech Republic) published &lt;a href="https://www.root.cz/clanky/tvorba-2d-i-3d-grafiky-a-animaci-v-go-s-vyuzitim-projektu-gogpu/" rel="noopener noreferrer"&gt;88 minutes of tutorial content&lt;/a&gt; covering the ecosystem with 54 working examples — the first serious European press coverage of Go GPU graphics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Games are happening.&lt;/strong&gt; @kivutar built &lt;a href="https://www.youtube.com/watch?v=KgdScyi_xZ0" rel="noopener noreferrer"&gt;Goro — a Ragnarok Online client&lt;/a&gt; using gogpu/ui in Pure Go (no CGO), with working UI, mercenaries, and gameplay. @darkliquid's &lt;a href="https://github.com/gogpu/gogpu/issues/163" rel="noopener noreferrer"&gt;Quake 1 port&lt;/a&gt; runs on our Vulkan stack. The Go game dev community is real — and it needs a 3D library that doesn't require CGO.&lt;/p&gt;

&lt;h2&gt;
  
  
  By the Numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;g3d LOC&lt;/td&gt;
&lt;td&gt;~11,700&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests&lt;/td&gt;
&lt;td&gt;374&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU backends&lt;/td&gt;
&lt;td&gt;5 (Vulkan, Metal, DX12, GLES, Software)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Examples&lt;/td&gt;
&lt;td&gt;3 (hello-cube, fullscreen-overlay, viewport3d)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-repo bugs found&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-repo bugs fixed&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Releases&lt;/td&gt;
&lt;td&gt;3 (v0.1.2 → v0.1.4, Jul 26 – Aug 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ecosystem total&lt;/td&gt;
&lt;td&gt;1.2M+ LOC Pure Go&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Standalone 3D&lt;/span&gt;
go run github.com/gogpu/g3d/examples/hello-cube@latest

&lt;span class="c"&gt;# Fullscreen 3D + 2D overlay&lt;/span&gt;
go run github.com/gogpu/g3d/examples/fullscreen-overlay@latest

&lt;span class="c"&gt;# 3D inside UI widget&lt;/span&gt;
go run github.com/gogpu/g3d/examples/viewport3d@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Select your GPU backend:&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="nv"&gt;GOGPU_GRAPHICS_API&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;vulkan   go run ./examples/hello-cube/
&lt;span class="nv"&gt;GOGPU_GRAPHICS_API&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dx12     go run ./examples/hello-cube/
&lt;span class="nv"&gt;GOGPU_GRAPHICS_API&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;software go run ./examples/hello-cube/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;g3d v0.1.4 has the foundation: scene graph, PBR materials, forward renderer, and two production-ready integration patterns. Here's what's coming:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 2 — Real Materials.&lt;/strong&gt; Cook-Torrance BRDF replaces Blinn-Phong. Shadow mapping (directional + point). Normal maps, metallic/roughness textures, emissive maps. This is where g3d goes from "demo-ready" to "product-ready."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 3 — GLTF 2.0.&lt;/strong&gt; Binary &lt;code&gt;.glb&lt;/code&gt; and JSON &lt;code&gt;.gltf&lt;/code&gt; loading with PBR materials, skeletal animation, and morph targets. GLTF is the "JPEG of 3D" — every modeling tool exports it, every engine imports it. g3d will too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 4 — Scale.&lt;/strong&gt; Instance batching for thousands of objects. Environment maps for reflections. Post-processing pipeline (bloom, tone mapping, FXAA). Skybox.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 5 — Performance.&lt;/strong&gt; BVH-accelerated frustum culling, LOD (level of detail), SIMD math via Go 1.25+ &lt;code&gt;goexperiment.simd&lt;/code&gt;. The &lt;a href="https://github.com/gomlx/gomlx" rel="noopener noreferrer"&gt;GoMLX PackGEMM&lt;/a&gt; team proved 14x speedup with Pure Go AVX-512 — same approach applies to matrix math.&lt;/p&gt;

&lt;p&gt;The rendering library was the last missing piece in Go's graphics ecosystem. 2D graphics, GUI toolkit, shader compiler, audio engine, system tray — all Pure Go, all shipping. Now 3D joins them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How You Can Help
&lt;/h2&gt;

&lt;p&gt;This project grows through real-world usage. Every bug report from a different GPU, every feature request from a real application, every "it works on my AMD/NVIDIA/Apple M4" — that's data we can't generate alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test it on your hardware.&lt;/strong&gt; We develop on Intel Iris Xe. AMD, NVIDIA, Apple Silicon, Adreno — we need your GPU. A simple &lt;code&gt;go run ./examples/hello-cube/&lt;/code&gt; and a one-line issue "works on RTX 4080 / Vulkan" is genuinely valuable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build something with it.&lt;/strong&gt; A 3D file viewer. A data visualizer. A game prototype. A CAD preview panel. The API is designed to be embedded — g3d doesn't own your application, you own g3d. The integration patterns in this article work today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Report what's missing.&lt;/strong&gt; We're tracking Phase 2–5 features, but priority depends on what people actually need. Shadows? GLTF? Instance batching? Textures? &lt;a href="https://github.com/orgs/gogpu/discussions" rel="noopener noreferrer"&gt;Tell us&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spread the word.&lt;/strong&gt; Star the repos, share the examples, write about your experience. Go's graphics ecosystem has been invisible for 17 years because nobody knew it existed. That changes when the community talks about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contribute code.&lt;/strong&gt; The &lt;a href="https://github.com/gogpu/g3d/blob/main/CONTRIBUTING.md" rel="noopener noreferrer"&gt;CONTRIBUTING.md&lt;/a&gt; has everything you need. Good first issues are labeled. We review PRs within 24 hours. Every contributor gets credit in the CHANGELOG and release notes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Support the project.&lt;/strong&gt; We accept donations via &lt;a href="https://opencollective.com/gogpu" rel="noopener noreferrer"&gt;Open Collective&lt;/a&gt; to fund development, testing on diverse GPU hardware, and CI infrastructure. Every contribution helps us test on more platforms and ship faster.&lt;/p&gt;

&lt;p&gt;The ecosystem is 1.2M+ lines of Pure Go — but the codebase isn't what makes it real. Users make it real. Applications make it real. The more people building on g3d, the faster it reaches the quality bar that Go deserves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;g3d:&lt;/strong&gt; &lt;a href="https://github.com/gogpu/g3d" rel="noopener noreferrer"&gt;github.com/gogpu/g3d&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GoGPU ecosystem:&lt;/strong&gt; &lt;a href="https://github.com/gogpu" rel="noopener noreferrer"&gt;github.com/gogpu&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discussions:&lt;/strong&gt; &lt;a href="https://github.com/orgs/gogpu/discussions" rel="noopener noreferrer"&gt;github.com/orgs/gogpu/discussions&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;root.cz coverage (Czech):&lt;/strong&gt; &lt;a href="https://www.root.cz/clanky/tvorba-2d-i-3d-grafiky-a-animaci-v-go-s-vyuzitim-projektu-gogpu/" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; · &lt;a href="https://www.root.cz/clanky/tvorba-2d-i-3d-grafiky-a-animaci-v-go-s-vyuzitim-projektu-gogpu-2-cast/" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Go waited 17 years for a professional graphics ecosystem. We're building it — and 3D rendering just shipped. Now we need you building on it.&lt;/p&gt;

</description>
      <category>go</category>
      <category>gamedev</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
    <item>
      <title>Go's sql.Null[T] Will Never Get JSON Support. Here's What We Built Instead.</title>
      <dc:creator>Andrey Kolkov</dc:creator>
      <pubDate>Sat, 04 Jul 2026 18:52:37 +0000</pubDate>
      <link>https://dev.to/kolkov/gos-sqlnullt-will-never-get-json-support-heres-what-we-built-instead-2apk</link>
      <guid>https://dev.to/kolkov/gos-sqlnullt-will-never-get-json-support-heres-what-we-built-instead-2apk</guid>
      <description>&lt;p&gt;Go 1.22 gave us &lt;code&gt;sql.Null[T]&lt;/code&gt; — a generic nullable type for SQL. Problem solved?&lt;/p&gt;

&lt;p&gt;No. Try marshaling it to JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"V"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Alice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"Valid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's not &lt;code&gt;"Alice"&lt;/code&gt;. That's not &lt;code&gt;null&lt;/code&gt;. That's a broken API response.&lt;/p&gt;

&lt;p&gt;And it will &lt;strong&gt;never be fixed&lt;/strong&gt;. &lt;a href="https://github.com/golang/go/issues/68375" rel="noopener noreferrer"&gt;Issue #68375&lt;/a&gt; — proposal to add &lt;code&gt;MarshalJSON&lt;/code&gt; to &lt;code&gt;sql.Null[T]&lt;/code&gt; — was &lt;strong&gt;closed as infeasible&lt;/strong&gt;. Go's policy forbids adding marshal methods to types that already have a "reasonable" default marshaling. The struct marshaling &lt;code&gt;{"V":...,"Valid":...}&lt;/code&gt; counts as "reasonable."&lt;/p&gt;

&lt;p&gt;This is a permanent gap in Go's standard library.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Bad Options (Before opt)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;sql.Null[T]&lt;/code&gt; — SQL works, JSON broken
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="k"&gt;struct&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;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Null&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="s"&gt;`json:"name"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c"&gt;// Marshals to: {"name":{"V":"Alice","Valid":true}}&lt;/span&gt;
&lt;span class="c"&gt;// You wanted:  {"name":"Alice"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. &lt;code&gt;*string&lt;/code&gt; — JSON works, everything else is awkward
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"name"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c"&gt;// Pointer allocation overhead on every value&lt;/span&gt;
&lt;span class="c"&gt;// Can't distinguish "field absent" from "field is null"&lt;/span&gt;
&lt;span class="c"&gt;// user.Name == nil — is this "not set" or "set to null"?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. &lt;code&gt;guregu/null&lt;/code&gt; — Works, but carries 10 years of legacy
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/guregu/null" rel="noopener noreferrer"&gt;guregu/null&lt;/a&gt; (2,070 stars) is the de facto standard. It works. But:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;v1→v6 legacy&lt;/strong&gt; — API evolved over 10 years, backward compatibility constraints&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No three-state&lt;/strong&gt; — can't distinguish "field absent" from "field null" (critical for PATCH APIs)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;Map&lt;/code&gt;/&lt;code&gt;FlatMap&lt;/code&gt;&lt;/strong&gt; — no functional composition&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;OrNull&lt;/code&gt; constructors&lt;/strong&gt; — every project writes the same boilerplate helpers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generic &lt;code&gt;Value[T]&lt;/code&gt;&lt;/strong&gt; has &lt;code&gt;MarshalText&lt;/code&gt; and &lt;code&gt;Equal&lt;/code&gt; commented out — couldn't make them work generically&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  opt: Option&amp;lt;T&amp;gt; for Go
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/coregx/opt" rel="noopener noreferrer"&gt;coregx/opt&lt;/a&gt; — designed from scratch for Go 1.24+. No legacy, no compromises.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="s"&gt;"github.com/coregx/opt"&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="k"&gt;struct&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;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt; &lt;span class="s"&gt;`json:"name"`&lt;/span&gt;
    &lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt; &lt;span class="s"&gt;`json:"email,omitzero"`&lt;/span&gt;
    &lt;span class="n"&gt;Age&lt;/span&gt;   &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Int&lt;/span&gt;    &lt;span class="s"&gt;`json:"age"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StringFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Alice"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;Age&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IntOrNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c"&gt;// 0 means "not set" → null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;// {"name":"Alice","age":null}&lt;/span&gt;
&lt;span class="c"&gt;// Email omitted (omitzero) — not null, just absent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;JSON works. SQL works. No pointer overhead. No boilerplate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Makes opt Different
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Three-State &lt;code&gt;Field[T]&lt;/code&gt; — The PATCH API Killer Feature
&lt;/h3&gt;

&lt;p&gt;Every REST API with PATCH endpoints has this problem: how do you distinguish "the client didn't send this field" from "the client explicitly set it to null"?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;PatchUser&lt;/span&gt; &lt;span class="k"&gt;struct&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;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="s"&gt;`json:"name,omitzero"`&lt;/span&gt;
    &lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="s"&gt;`json:"email,omitzero"`&lt;/span&gt;
    &lt;span class="n"&gt;Age&lt;/span&gt;   &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="s"&gt;`json:"age,omitzero"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Client sends: {"name":"John","email":null}&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;patch&lt;/span&gt; &lt;span class="n"&gt;PatchUser&lt;/span&gt;
&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsValue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c"&gt;// true  → set name to "John"&lt;/span&gt;
&lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsNull&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c"&gt;// true  → set email to NULL in DB&lt;/span&gt;
&lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Age&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsAbsent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c"&gt;// true  → don't touch age&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three states: &lt;strong&gt;absent&lt;/strong&gt; (don't touch), &lt;strong&gt;null&lt;/strong&gt; (set to NULL), &lt;strong&gt;value&lt;/strong&gt; (set to value).&lt;/p&gt;

&lt;p&gt;Rust does this with &lt;code&gt;Option&amp;lt;Option&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt;. Kotlin with nullable + optional. In Go, no library properly solves this — guregu/null doesn't distinguish absent from null, and pointer-based approaches (&lt;code&gt;**T&lt;/code&gt;) are impractical. coregx/opt's &lt;code&gt;Field[T]&lt;/code&gt; is the first clean solution with concrete typed constructors, &lt;code&gt;OrNull&lt;/code&gt;, &lt;code&gt;FlatMap&lt;/code&gt;, and a &lt;code&gt;zero/&lt;/code&gt; subpackage.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;OrNull&lt;/code&gt; Constructors — No More Boilerplate
&lt;/h3&gt;

&lt;p&gt;Every project using nullable types with a database writes the same helpers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// This is in EVERY Go project with nullable DB fields&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;optStr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;optInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IntFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&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;With opt, this is built in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;CompanyRow&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;City&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StringOrNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;company&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;City&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;     &lt;span class="c"&gt;// "" → null&lt;/span&gt;
    &lt;span class="n"&gt;OGRN&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StringOrNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OGRN&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;         &lt;span class="c"&gt;// "" → null&lt;/span&gt;
    &lt;span class="n"&gt;Count&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IntOrNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;company&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EmployeeCount&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="c"&gt;// 0 → null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One function per type. Zero boilerplate. Available for all 8 concrete types including &lt;code&gt;BoolOrNull&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Functional API — Map, FlatMap, Equal
&lt;/h3&gt;

&lt;p&gt;Inspired by Rust's &lt;code&gt;Option&amp;lt;T&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Transform if valid, propagate null&lt;/span&gt;
&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"  Alice  "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Map&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="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c"&gt;// opt.Option[string]{"Alice", true}&lt;/span&gt;
&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;  &lt;span class="c"&gt;// opt.Option[int]{5, true}&lt;/span&gt;

&lt;span class="c"&gt;// Chain operations that may produce null&lt;/span&gt;
&lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FlatMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Option&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Atoi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c"&gt;// Nil-safe comparison&lt;/span&gt;
&lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;42&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;42&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c"&gt;// true&lt;/span&gt;
&lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;42&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c"&gt;// false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;guregu/null has &lt;code&gt;ValueOr&lt;/code&gt;. That's it. No &lt;code&gt;Map&lt;/code&gt;, no &lt;code&gt;FlatMap&lt;/code&gt;, no &lt;code&gt;OrElse&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Generic &lt;code&gt;Option[T]&lt;/code&gt; That Actually Works
&lt;/h3&gt;

&lt;p&gt;guregu's generic &lt;code&gt;Value[T]&lt;/code&gt; has &lt;code&gt;MarshalText&lt;/code&gt; and &lt;code&gt;Equal&lt;/code&gt; &lt;strong&gt;commented out&lt;/strong&gt; in the source code — they couldn't make them work generically.&lt;/p&gt;

&lt;p&gt;opt's &lt;code&gt;Option[T]&lt;/code&gt; is fully functional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Works with ANY type&lt;/span&gt;
&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;From&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MyCustomStruct&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&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="c"&gt;// {"Name":"test"}&lt;/span&gt;

&lt;span class="n"&gt;null&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MyCustomStruct&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c"&gt;// null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. &lt;code&gt;zero/&lt;/code&gt; Subpackage — Alternative Semantics
&lt;/h3&gt;

&lt;p&gt;Sometimes you want zero values to &lt;strong&gt;be&lt;/strong&gt; null, and null to marshal as zero (not &lt;code&gt;null&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="s"&gt;"github.com/coregx/opt/zero"&lt;/span&gt;

&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;zero&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StringFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;// Invalid — empty = null&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// "" (not "null")&lt;/span&gt;

&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;zero&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IntFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c"&gt;// Invalid — 0 = null&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c"&gt;// 0 (not "null")&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;&lt;code&gt;From("")&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Marshal null&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;opt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Valid (empty string)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;opt/zero&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Invalid (null)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;""&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;coregx/opt&lt;/th&gt;
&lt;th&gt;guregu/null&lt;/th&gt;
&lt;th&gt;&lt;code&gt;*T&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;sql.Null[T]&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Generic nullable&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;Option[T]&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;No JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Three-state (PATCH)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;Field[T]&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concrete types (String, Int...)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8 types&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8 types&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;OrNull&lt;/code&gt; constructors&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FieldFromOption&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Map / FlatMap&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OrElse (lazy)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Equal&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON marshal&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Broken&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL Scanner/Valuer&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;omitzero (Go 1.24+)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;zero-is-null subpackage&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;zero/&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;zero/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;json/v2 compatible&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legacy code&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;None&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;v1→v6&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Performance
&lt;/h2&gt;

&lt;p&gt;Zero-allocation unmarshal. Bool operations under 2 nanoseconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BenchmarkBoolMarshalJSON     0.85 ns/op    0 allocs
BenchmarkBoolUnmarshalJSON   2.1 ns/op     0 allocs
BenchmarkIntUnmarshalJSON    193 ns/op     1 alloc
BenchmarkStringUnmarshalJSON 137 ns/op     0 allocs
BenchmarkStructMarshalJSON   876 ns/op     9 allocs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why New Projects Should Start with opt
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sql.Null[T]&lt;/code&gt; will never get JSON support&lt;/strong&gt; — this is official Go team position (&lt;a href="https://github.com/golang/go/issues/68375" rel="noopener noreferrer"&gt;#68375&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;*T&lt;/code&gt; can't do three-state&lt;/strong&gt; — nil means both "absent" and "null"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;guregu/null works&lt;/strong&gt; but carries v1→v6 legacy and has no PATCH support&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;opt starts clean&lt;/strong&gt; — Go 1.24+, generics-first, no backward compatibility burden&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Field[T]&lt;/code&gt; is unique&lt;/strong&gt; — no other Go library properly solves the PATCH three-state problem&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero dependencies&lt;/strong&gt; — only Go stdlib&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;json/v2 ready&lt;/strong&gt; — works today, optimizable when json/v2 goes stable&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go get github.com/coregx/opt@v0.3.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="s"&gt;"github.com/coregx/opt"&lt;/span&gt;

&lt;span class="c"&gt;// Always valid&lt;/span&gt;
&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StringFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Alice"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// Zero means "not set"&lt;/span&gt;
&lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IntOrNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c"&gt;// null&lt;/span&gt;

&lt;span class="c"&gt;// From pointer (nil → null)&lt;/span&gt;
&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StringFromPtr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;emailPtr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// Three-state for PATCH&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;PatchRequest&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Bio&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="s"&gt;`json:"bio,omitzero"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full API and examples: &lt;a href="https://github.com/coregx/opt" rel="noopener noreferrer"&gt;github.com/coregx/opt&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Help Us Get to v1.0.0
&lt;/h2&gt;

&lt;p&gt;opt is in active development. We're heading toward a stable v1.0.0 and need your help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Try it&lt;/strong&gt; in your project — replace &lt;code&gt;*string&lt;/code&gt; or &lt;code&gt;guregu/null&lt;/code&gt; and tell us how it goes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report issues&lt;/strong&gt; — edge cases, driver compatibility, unexpected behavior&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Suggest features&lt;/strong&gt; — what nullable types should do that no library does yet&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send PRs&lt;/strong&gt; — new types, better tests, documentation improvements&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share&lt;/strong&gt; — if opt solved a problem for you, tell your team or write about it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every bug report, feature idea, and PR brings us closer to a stable API.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/coregx/opt" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://github.com/coregx/opt/issues" rel="noopener noreferrer"&gt;Issues&lt;/a&gt; | &lt;a href="https://pkg.go.dev/github.com/coregx/opt" rel="noopener noreferrer"&gt;pkg.go.dev&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;opt is part of the &lt;a href="https://github.com/coregx" rel="noopener noreferrer"&gt;coregx&lt;/a&gt; ecosystem — high-performance Go libraries for production applications.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>api</category>
    </item>
  </channel>
</rss>
