Picto.Video has a small analytics tool that runs on a small droplet. It reads raw usage events and builds one SQLite database, and our reports read from that database. We wrote it in Dart. This autumn we moved it to Go. AI coding agents wrote the Go version, and we checked it against the Dart version row by row.The tool in this post
Raw usage eventsinput
SQLite databaseone file
Reportsread the database
Why Go suits small server tools when agents write the code
Agents now write most of the code for tools like this one. So we judge a language by what happens after the code is written.
The compiler checks the agent's work
Go is statically typed, and its compiler rejects unused imports and unused variables. An agent gets that feedback in seconds, and fixes it before a person sees the code. A Python script finds a wrong type or a misspelt name when that line runs, which on a server tool can be days later. Dart is typed too, so this point is mostly about Python.When a wrong type shows up
GoDartPythonwritebuildtestdeployproduction
Go also fails the build on an unused import or variable. That gives an agent a fast, hard signal on every edit.
The binary is the whole deploy
GOOS=linux GOARCH=amd64 go build on a laptop gives one static Linux binary. Our port has one direct dependency, a SQLite driver written in pure Go, so the build needs no C compiler. The binary is 6.6 MB, and the server needs nothing else installed. A Python script needs an interpreter and its packages on the server, and those drift over time. Dart can also compile to a native binary, and the old tool ran that way.What has to be on the server
Go
$ GOOS=linux GOARCH=amd64 go build
Python script
$ scp tool.py server:
tool.py
packages pinned?
interpreter which version?
Memory stays low
We gave the port a budget of 200 MB peak memory on the full data, because a background tool on a shared droplet has to leave room for everything else. Go peaked at 83 MB. Dart peaked at 196 MB on the same input.Peak memory, same input
42%
of Dart's peak memory is what Go used on the full history
GoDart
200 MB budget
One week, from scratch
Full history, from scratch
Live database, normal run
Peak resident memory from /usr/bin/time -l on the laptop. The table below has the same numbers and the run times.
The code says what it does
Go makes you choose the things Dart decided for you. Map iteration order is random on purpose, so any code that needs an order has to sort. JSON numbers keep their literal form unless you format them. In the port, every one of those choices is a line you can read. The table further down lists them.
The extra length costs little
The Go port is 2.3 times the line count of the Dart one. An agent wrote those lines in minutes. A person still reads them. Go code reads the same way in every file: the call, then the error check on the next line. That makes a long file quick to review.Program code, lines
Go code that copies Dart behaviour
How we checked the port
Before the agent wrote any Go, we decided what "done" means. The Go tool has to write the same database as the Dart tool, every table and every row.
- We copied the production inputs to a laptop, read-only.
- We built both tools from the same commit.
- We ran each one on its own copy of the inputs, with the clock zone set to UTC.
- We dumped every table and view of both databases in a fixed order and diffed the dumps.
- We measured each run with
/usr/bin/time -l.
The check
Production inputsread-only copy
Dart toolcopy A
Database Adumped in fixed order
0tables and views differdiff of the two dumps
Go toolcopy B
Database Bdumped in fixed order
check the check One value changed by hand in a copy of a database. The diff failed, as it has to.
The diff skips the two values that hold the time of the run. To test the diff itself, we changed one value in a copy of a database. The diff caught it.
| Run | Tables and views that differ |
|---|
| One week of data, built from scratch | none |
| Full history, built from scratch | none |
| The live database, normal run with a schema upgrade | none |
| A second run on the same database with no new data | none |
The stored CREATE statement of every table, index and view matched as well. That includes a column the schema upgrade adds with ALTER TABLE.
We then ran the Linux build inside a Linux container. The diff came back empty again, and peak memory on the full history was 76.3 MB.
Memory and time
These figures come from the laptop. Peak RSS is the most physical memory the process held at one time.
| Run | Go peak RSS | Dart peak RSS | Go time | Dart time |
|---|
| One week, from scratch | 58.6 MB | 112.3 MB | 4.5 s | 6.9 s |
| Full history, from scratch | 83.0 MB | 196.3 MB | 135 s | 144 s |
| Live database, normal run | 68.9 MB | 105.5 MB | 19.0 s | 16.1 s |
Go used 42% of the memory Dart used on the full history. Speed came out about even. Go was a little faster on the full build and a little slower on the short runs.
Where Dart and Go differ
Most of the work went into small things the two standard libraries do differently. Each one would have shown up in the diff.Same JSON in, different text out
Input{"z": 1e2, "a": "<b>"}
Dart{"z":100.0,"a":"<b>"}
Go default{"a":"\u003cb\u003e","z":100}
key ordernumber formatHTML escaping
Decode, then encode again, with each standard library's defaults. Our database already held the Dart line, so the Go port has to write the Dart line.
| What Dart does | What Go does by default | What would differ |
|---|
| A decoded JSON map keeps its key order | Map order is random | Stored JSON text |
1.50 encodes as 1.5, and 1e2 as 100.0 | Keeps the literal, or prints 100 | Stored JSON text |
JSON output leaves <, > and & as they are | Escapes them as \u003c and so on | Stored JSON text |
| Strings sort by UTF-16 code unit | Strings sort by UTF-8 byte | Order of keys that hold emoji |
5.0 prints as 5.0, and a map as {a: 1} | 5 and map[a:1] | Text columns |
| A directory lists in raw disk order | os.ReadDir sorts by name | Event ids and tie-breaks |
| Maps and sets iterate in insertion order | Random order | Tie-breaks between equal values |
| Each bad UTF-8 sequence becomes one U+FFFD | A run of bad bytes becomes one U+FFFD | Event text |
Lines split on \n, \r\n and a lone \r | \n only | Line numbers |
| Its own date parser, fractions cut to microseconds | Fixed time.Parse layouts | Timestamps |
int.tryParse accepts 0x1F and surrounding spaces | strconv rejects both | Parsed numbers |
| Case-insensitive regex folds ASCII letters only | (?i) folds Unicode, so ſ matches s | Pattern matches |
One difference is left. Take a JSON string that holds a lone \ud800 escape. Dart keeps the escape and Go stores U+FFFD. Our data has no such string, so we left it.
What we learnt
- The old tool's defaults were the spec. Nobody chose Dart's key order or its number format. Our reports depended on both anyway. A port has to copy what the old code did, and that includes what its libraries did.
- Write the check before the port. The agent wrote the Go tool, its tests and a harness for the Dart reference output in about 25 minutes. Our trust came from the diff.
- Test the check. A diff that always says "no difference" proves nothing. One planted change showed ours works.
- Write down the differences you accept. The lone
\ud800 case is documented, with the reason we left it. - Run the real build in the real shape. The laptop build passed first. The Linux binary in a container is the one that ships, so we diffed that one too.
The gates between the agent and production
1
Agent writes Goabout 25 min3
Tests42 ported, 519 edge inputs5
Memory checkunder 200 MBa failure at any gate goes back to the agent
Tests and size
We ported all 42 Dart tests to Go with the same names and fixtures, and all 42 pass. We also ran 519 edge-case inputs through both tools. Every output matched.
| Dart | Go |
|---|
| Program code | 1,459 lines | 3,423 lines |
| Of which, copies of Dart behaviour | none | 1,004 lines |
| Test code | 1,281 lines | 1,321 lines |
| Linux binary | 7.2 MB | 6.6 MB, static, stripped |
Where it is now
The Go tool runs in production now, in place of the Dart one.