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
Analytics tool
Dart→Go
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

Go
at build
Dart
at build
Python
when the line runs
writebuildtestdeployproduction
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
tool 6.6 MB, static

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
58.6 MB
112.3 MB
Full history, from scratch
83.0 MB
196.3 MB
Live database, normal run
68.9 MB
105.5 MB
050100150200 MB250 MB
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

Dart
1,459
Go
2,419
1,004
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.

  1. We copied the production inputs to a laptop, read-only.
  2. We built both tools from the same commit.
  3. We ran each one on its own copy of the inputs, with the clock zone set to UTC.
  4. We dumped every table and view of both databases in a fixed order and diffed the dumps.
  5. 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.

RunTables and views that differ
One week of data, built from scratchnone
Full history, built from scratchnone
The live database, normal run with a schema upgradenone
A second run on the same database with no new datanone

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.

RunGo peak RSSDart peak RSSGo timeDart time
One week, from scratch58.6 MB112.3 MB4.5 s6.9 s
Full history, from scratch83.0 MB196.3 MB135 s144 s
Live database, normal run68.9 MB105.5 MB19.0 s16.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 doesWhat Go does by defaultWhat would differ
A decoded JSON map keeps its key orderMap order is randomStored JSON text
1.50 encodes as 1.5, and 1e2 as 100.0Keeps the literal, or prints 100Stored JSON text
JSON output leaves <, > and & as they areEscapes them as \u003c and so onStored JSON text
Strings sort by UTF-16 code unitStrings sort by UTF-8 byteOrder 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 orderos.ReadDir sorts by nameEvent ids and tie-breaks
Maps and sets iterate in insertion orderRandom orderTie-breaks between equal values
Each bad UTF-8 sequence becomes one U+FFFDA run of bad bytes becomes one U+FFFDEvent text
Lines split on \n, \r\n and a lone \r\n onlyLine numbers
Its own date parser, fractions cut to microsecondsFixed time.Parse layoutsTimestamps
int.tryParse accepts 0x1F and surrounding spacesstrconv rejects bothParsed numbers
Case-insensitive regex folds ASCII letters only(?i) folds Unicode, so ſ matches sPattern 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 gates between the agent and production

1
Agent writes Goabout 25 min
2
Compilerseconds
3
Tests42 ported, 519 edge inputs
4
Row diffagainst Dart
5
Memory checkunder 200 MB
a 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.

DartGo
Program code1,459 lines3,423 lines
Of which, copies of Dart behaviournone1,004 lines
Test code1,281 lines1,321 lines
Linux binary7.2 MB6.6 MB, static, stripped

Where it is now

The Go tool runs in production now, in place of the Dart one.