A sans-IO GPX reader and writer for Zig
  • Zig 93.9%
  • Nix 6.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie f6db0f6743
All checks were successful
test / test (push) Successful in 8m16s
test / docs (push) Successful in 5m41s
Say how to run the fuzz loop on more than one core
The loop is single-threaded and the machine is not. What is left to find
turns up at about one input in thirty million, so six campaigns with
different seeds are six times the chance of meeting one in the same
wall-clock -- which is how the last four findings were found, after a
single campaign of fifty million had come back clean.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 11:59:08 -05:00
.forgejo/workflows Regenerate the dependency expression, and stop formatting somebody else's code 2026-09-19 10:43:43 -05:00
LICENSES A sans-IO GPX reader and writer 2026-09-19 10:38:19 -05:00
src Four things the fuzz loop found, and one reading the code did 2026-09-19 11:36:44 -05:00
tests Four things the fuzz loop found, and one reading the code did 2026-09-19 11:36:44 -05:00
tools A sans-IO GPX reader and writer 2026-09-19 10:38:19 -05:00
.gitignore A sans-IO GPX reader and writer 2026-09-19 10:38:19 -05:00
build.zig A sans-IO GPX reader and writer 2026-09-19 10:38:19 -05:00
build.zig.zon A sans-IO GPX reader and writer 2026-09-19 10:38:19 -05:00
build.zig.zon.nix Regenerate the dependency expression, and stop formatting somebody else's code 2026-09-19 10:43:43 -05:00
flake.lock A sans-IO GPX reader and writer 2026-09-19 10:38:19 -05:00
flake.nix A sans-IO GPX reader and writer 2026-09-19 10:38:19 -05:00
package.nix A sans-IO GPX reader and writer 2026-09-19 10:38:19 -05:00
README.md Say how to run the fuzz loop on more than one core 2026-09-19 11:59:08 -05:00
REUSE.toml A sans-IO GPX reader and writer 2026-09-19 10:38:19 -05:00

zig-gpx

A sans-IO reader and writer for GPX, the GPS Exchange Format, in Zig 0.16.

Sans-IO means what it says: nothing here opens a file, connects to anything, or blocks. The reader is handed bytes and hands back events; the writer is handed a std.Io.Writer and writes to it. Where the bytes come from and where they go is the caller's business, which is what lets the same code read a track out of an HTTP response, a memory-mapped file, a @embedFile, or a decompressor's output buffer.

The API documentation is generated from the doc comments, which is where most of the explanation lives.

Where this lives

The repository's home is my Forgejo instance, which is where the issues, the continuous integration and the published documentation are:

$ git clone https://git.jcollie.dev/jeff/zig-gpx.git

It is mirrored on Tangled at https://tangled.org/jcollie.dev/zig-gpx, a forge built on the AT Protocol, where a repository is addressed by its owner's identity rather than by a server name:

$ git clone https://tangled.org/jcollie.dev/zig-gpx

It is also published on Radicle, a peer-to-peer forge that needs no account on anything. A Radicle repository is findable only by its repository ID, so this is that ID:

$ rad clone rad:z3xmKn8khWvxqvE35GxBVKWvaKWCF

rad clone finds seeds through your local node's routing table rather than through a known host, so the node has to be running before it can find anything:

$ rad node start

If you already have the repository and only want to help host it, seeding it tells your node to carry a copy for others:

$ rad seed rad:z3xmKn8khWvxqvE35GxBVKWvaKWCF

Any of the three is the whole project, on the main branch, with the same history.

Installation

$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-gpx.git
const gpx = b.dependency("gpx", .{
    .target = target,
    .optimize = optimize,
});
your_module.addImport("gpx", gpx.module("gpx"));

Usage

There are two ways in, and which one to use is decided by the question being asked.

Reader pulls one point at a time and builds nothing. A day of hiking logged once a second is ninety thousand track points and six megabytes, and the questions usually asked of one — how far, how long, how much climb — need the points one at a time.

const gpx = @import("gpx");

var reader: gpx.Reader = .init(gpa, source, .{});
defer reader.deinit();

var total: f64 = 0;
var previous: ?gpx.Point = null;
while (true) switch (try reader.next()) {
    .track_point => |p| {
        if (previous) |q| total += q.distanceTo(p);
        previous = p;
    },
    // The pen lifts between segments: the gap is where the recording
    // stopped, not distance that was travelled.
    .segment_end => previous = null,
    .end => break,
    else => {},
};

Document reads the whole file into one arena, which is what the other questions need — a bounding box, the file written back out with something changed, anything that has to look at two points that are not adjacent.

var doc = try gpx.Document.parse(gpa, source, .{});
defer doc.deinit();

for (doc.tracks) |track| {
    std.debug.print("{s}: {d:.2} km\n", .{
        track.name orelse "(unnamed)",
        track.length() / 1000,
    });
}

const out = try doc.toOwnedSlice(gpa, .{ .indent = "\t" });
defer gpa.free(out);

Writing without a document in hand is a third way, and the one a converter wants: Writer takes a std.Io.Writer and the elements in the order the format puts them.

var writer: gpx.Writer = .init(out, .{ .creator = "my program" });
try writer.begin(.{ .version = .@"1.1" });
try writer.beginTrack(.{ .name = "morning" });
try writer.beginSegment();
try writer.trackPoint(.{ .lat = 41.7396496, .lon = -93.6313859, .elevation = 293.3 });
try writer.endSegment(null);
try writer.endTrack();
try writer.end();

What it does

  • Reads both versions of the format. GPX 1.1 is what everything writes today; GPX 1.0 is what a decade of devices and a great many archives hold, and it reads into the same types — its metadata is loose in the root element rather than in a <metadata>, and its <url> and <urlname> pair is a Link. Which version a document is written as is one field of the writer's options, so converting between them costs nothing.
  • Keeps <extensions> verbatim. What is inside one belongs to whoever put it there — Garmin's heart rate and cadence, OsmAnd's speed, GDAL's projection — so the whole element is kept as a slice of the document, start tag and all, and written back out untouched. Nothing here has to know any of them, and none of them are lost. The start tag is included rather than only what is between the tags because a namespace may be declared on the element itself — <extensions xmlns:q="urn:y"> scopes q to exactly the subtree that uses it — and a copy without that declaration is a subtree no namespace-aware reader accepts. The declarations on the root element are kept for the same reason, which is what makes a Garmin file's ns3: prefix still mean something after a round trip.
  • Allocates almost nothing while streaming. A string is a slice of the document unless it had an entity reference to resolve, and the <extensions> never copy at all, so reading ninety thousand points touches the allocator a handful of times rather than a hundred thousand.
  • Writes in the order the schema asks for. GPX is a sequence and not a bag: <ele> before <time>, every <wpt> before the first <rte>, <extensions> last. Calling the writer's methods out of that order is a programming error and trips an assertion rather than producing a file that a validating reader rejects and a careless one misreads.
  • Does not borrow the source. Every string in a Document is copied into its arena, so the bytes it was parsed from may be freed the moment parse returns.

The gpx tool

A library nobody can point at a file is hard to believe in, and this is what the corpus of real tracks gets run through.

$ gpx info 2026-06-21_09-13_Sun.gpx
2026-06-21_09-13_Sun.gpx
  GPX 1.1 by OsmAnd+ 5.3.10
  name        2026-06-21_09-13_Sun
  waypoints   0
  routes      0
  tracks      1
    [0] (unnamed): 2 segments, 469 points, 22.32 km
         2026-06-21T14:13:00Z to 2026-06-21T14:58:05Z (0h45m)
  bounds      44.067280,-93.229499 to 44.099923,-93.051571
  points      469

gpx points writes every point as a line of latitude, longitude, elevation and time, which is what a spreadsheet or an awk one-liner wants. gpx fmt reads a file and writes it back out, optionally as the other version of the format (--gpx-1.0, --gpx-1.1) or with no whitespace at all (--compact). --stream makes info and points use the streaming reader rather than building a document, which is what a file of a few hundred megabytes wants.

Dates and times

A <time> is an instant, and an instant here is zig-datetime's Instant — nanoseconds since the Unix epoch — rather than a type of this library's own, so everything that library can do with one is available without converting anything. gpx.datetime is that library re-exported, so depending on this one is enough.

What this library decides is only which of ISO 8601's many shapes count as a GPX timestamp and which one to write back. It reads more than xsd:dateTime asks for — the basic form, an ordinal or week date, a comma for the decimal point, 24:00 as the end of its day, and a timestamp with no zone at all, which the schema does not allow and which every GPX file that writes one means as UTC. It reads less in one place: a date alone is not an instant, so anything short of whole seconds is refused rather than silently completed with zeros. Everything is written back as UTC with a Z, because that is the one form every reader agrees on.

What is not here

  • No validation against the schema. Reading is lenient by design: an element GPX does not define is skipped, because GPX is extended constantly and often carelessly — by tools that put their own elements straight into a <trk> rather than inside its <extensions> — and a reader that refused those would refuse a great many real files. Options.unknown_elements turns that into an error for a caller that wants one.
  • No geodesy beyond a distance. Point.distanceTo is the haversine formula on a sphere of the earth's mean radius, which is good to about 0.3% against the WGS 84 ellipsoid and far better than the points themselves. Anything needing more than that wants a library about ellipsoids, not one about XML.
  • No reprojection, no smoothing, no simplification. Those are things to do with a track, and this is the part that gets the track in and out.
  • Nothing fetched, ever. The XML underneath this is zxml, which records a SYSTEM or PUBLIC identifier and never dereferences one, so a document cannot make this open a file or reach the network.

Testing

$ zig build test

That runs the unit tests and replays the fuzz corpus. The property worth knowing about is the strong one:

$ zig build fuzz-run -- --seconds 600
$ zig build fuzz-run -- --iterations 500000 --target convert

Writing what was read, and reading that, must produce the same bytes a third time. A reader and a writer drift apart easily, because nothing else compares them: a field read and never written, or written in a place the reader does not look for it, is invisible until somebody's waypoints come back without their symbols. Comparing the fixed point rather than the first pass is what lets a deliberate normalization — a time rewritten as UTC, a number rewritten shortest, a 1.1 document written as 1.0 — pass while a loss does not.

A second property rides along: the source is freed before the document is written. Every string in a Document is copied into its arena precisely so that it outlives the bytes it came from, and the <extensions> are where that is easiest to get wrong, since the streaming reader hands those out as slices of the file it is reading.

The loop is single-threaded and the machine is not, so several campaigns with different seeds find more than one long campaign does: the bugs that are left turn up at around one input in thirty million, and six at once is six times the chance of meeting one.

$ for i in 1 2 3 4 5 6; do
    zig build fuzz-run -- --seconds 1200 --seed $i > run$i.log 2>&1 &
  done

The loop is this project's own rather than Zig's, because Zig 0.16.0's fuzzer cannot be used — a test executable will not build in fuzz mode without the one-line standard library patch the devshell applies, and even with it the table of program counters comes back empty, so there is no coverage feedback to have. What the loop has instead is a corpus of inputs that already parse, which for a parser is most of the way there.

A formatting check skips zig-pkg, which is where the package manager puts the dependencies: somebody else's source, which this project does not get to reformat.

$ zig fmt --check --exclude zig-pkg .

Documentation

$ zig build docs         # into zig-out/docs
$ zig build docs-serve   # and read it at http://127.0.0.1:8000

It has to be served rather than opened: the viewer fetches its sources and its WebAssembly at runtime, which a browser refuses to do from a file:// page.

References cited

  • [GPX 1.1] TopoGrafix, "GPX 1.1: the GPS Exchange Format", 9 August 2004, https://www.topografix.com/GPX/1/1/. The format this implements. The schema at https://www.topografix.com/GPX/1/1/gpx.xsd is the normative part and is what the writer's element order is taken from: gpxType, metadataType, wptType, rteType, trkType and trksegType are each a sequence, so the order the writer emits in is theirs rather than a choice.
  • [GPX 1.0] TopoGrafix, "GPX 1.0: the GPS Exchange Format", 2002, https://www.topografix.com/gpx_manual.asp. The older version, read but written only when asked for. Its schema is where <speed> and <course> live — on a <trkpt> alone, after <time> and before <magvar>, which is not where a reader would guess — and where the <url>/<urlname> pair that became <link> is defined.
  • [XML] Bray, T., Paoli, J., Sperberg-McQueen, C. M., Maler, E., and F. Yergeau, "Extensible Markup Language (XML) 1.0 (Fifth Edition)", W3C Recommendation REC-xml-20081126, 26 November 2008, https://www.w3.org/TR/2008/REC-xml-20081126/. §2.11 for the line-ending normalization and §3.3.3 for the attribute-value normalization that decides what the writer has to escape: a literal tab in an attribute comes back as a space and a &#9; comes back as a tab, which is the difference a round trip notices.
  • [XML Names] Bray, T., Hollander, D., Layman, A., Tobin, R., and H. S. Thompson, "Namespaces in XML 1.0 (Third Edition)", W3C Recommendation REC-xml-names-20091208, 8 December 2009, https://www.w3.org/TR/2009/REC-xml-names-20091208/. Why an unprefixed attribute is in no namespace whatever the default is bound to, which is what decides the prefix an xsi:schemaLocation is written with.
  • [XSD 2] Biron, P. V. and A. Malhotra, "XML Schema Part 2: Datatypes Second Edition", W3C Recommendation REC-xmlschema-2-20041028, 28 October 2004, https://www.w3.org/TR/2004/REC-xmlschema-2-20041028/. The lexical spaces GPX's values are drawn from: §3.2.7 dateTime, §3.2.3 decimal — which has no exponent and no INF or NaN, and is why the numbers are checked before they are parsed — and §3.2.8 gYear.