The previous article was about scigolib/hdf5, a pure Go HDF5 library born out of the need to read MATLAB data in Go. This is the other half of that story: scigolib/matlab, the library that actually reads the .mat files.
It reads and writes both MATLAB formats in pure Go, with no cgo:
- v5 (MATLAB v5 to v7.2): the classic binary format, including the zlib-compressed elements MATLAB writes by default;
- v7.3: HDF5 underneath, through scigolib/hdf5.
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.
What doesn't yet:
- structures and cell arrays are not decoded (in v5 you get the class; a v7.3 struct comes back as one variable per field);
- sparse matrices;
- complex numbers in v7.3 use a real/imag group, not MATLAB's compound layout;
- character arrays can be read but not written;
- the writer doesn't compress;
- an empty v5 cell
{}makesOpenfail.
Most of what I want to tell here are bugs, and nearly all of them share one cause:
Our tests mostly saw files we wrote ourselves.
Files saved with MATLAB's defaults read as empty
By default, MATLAB's save writes a Version 7 MAT file with compression on: each variable is wrapped in a miCOMPRESSED element. Up to v0.3.0, the reader skipped elements it didn't handle. So for a file saved with plain save data.mat, Open returned no error and zero variables.
Reproduced on the v0.3.0 tag, with SciPy's test file inner_outer_tbl_param.mat:
v0.3.0: 0 variables, err = nil v0.3.19: 12 variables 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 testdata, but no test opened them until v0.3.15.
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.
A bug report that waited three months
Issue #21 from @EtherHelm 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.
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 nzmax. Our encodeArrayFlags put them in the wrong words. And the reader had the mirror bug: it read the class from the second word.
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.
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 DataType came back as unknown, because the class was taken from nzmax, which is 0. A second bug sat next to it: the byte-order mapping was inverted, so the default output was big-endian.
The issue was opened on June 7 and answered and fixed on September 2. The plain reason: most of my time goes to GoGPU, Born and coregex, 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.
The fix (PR #23) put the spec layout on both sides and added two kinds of tests nobody can satisfy by agreeing with themselves:
- reading SciPy's reference
.matfiles; - golden-byte tests that compare our output with the spec, byte by byte.
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.
A 136-byte file that could ask for 2 GiB
The same class of bug as hdf5's GHSA-8p88-h28q-w6m9: 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.
The rest turned up while bumping the hdf5 dependency:
- an untyped
2^31constant used as anintbroke the build on linux/386, linux/arm and windows/386; - a claimed size of exactly 2 GiB slipped past the cap and panicked inside
makeon 386.
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.
The fix that broke bytes.Buffer
The release review of v0.3.17 found that the v0.3.16 fix had broken something else.
The bound measured how much input was left after reading the 128-byte header. A bytes.Buffer reports only its unread part, so the bound came out 128 bytes short, and every valid v5 file opened from a bytes.Buffer was rejected: tag size beyond end of file.
No test had passed a bytes.Buffer to Open. Now one test opens every test file through os.File, bytes.Reader, bytes.Buffer and a reader that hides its size.
The same release also caught a stream cut off inside a skipped element being accepted, because io.CopyN 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 io.ErrUnexpectedEOF.
MATLAB's own v7.3 files didn't open
This bug came to light during the fact-checking for this article, and it affected every v7.3 file MATLAB writes.
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 0x0200; the HDF5 signature starts at byte 512. Our Open looked for HDF5 only at offset 0, didn't find it, and handed the file to the v5 parser:
tag size beyond end of file: 169478669 bytes at offset 512 All our v7.3 test files came from our own writer, which writes no user block. Same blind spot, third time.
There was no MATLAB-written v7.3 file at hand, so the layout was built from the references: matio (Mat_Open reads the version at bytes 124–125 and opens 0x0200 through HDF5; Mat_Create73 sets a 512-byte user block), SciPy's _get_matfile_version, and the HDF5 C library's signature search, which looks at offset 0 and then at every power of two from 512. h5jam -u produced a test file byte-identical to "block + file", and h5dump opens it. v0.3.18 detects the format by the header version, as matio and SciPy do.
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 H5Pset_userblock. hdf5 v0.15.0 added it, and v0.3.19 writes the header the way matio's Mat_Create73 does. h5dump opens the files. Whether MATLAB loads them, nobody has confirmed yet.
Test data and docs that claimed too much
Two smaller ones, in the same spirit.
The 8 v7.3 test files were written in November 2025 by an early hdf5 version, and h5dump 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 testdata/legacy to prove such files still read.
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.
What I'd do differently
1. Your own files are not compatibility tests. 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.
2. A silent empty result is worse than an error. Zero variables and nil looked like success.
3. Measure the input before you consume it, and test every kind of reader your API accepts.
4. Don't rely on email alone for issues. #21 waited three months because the notification went unseen. With many projects, look at each one's open issues directly from time to time.
What's next
- MATLAB's compound layout for complex numbers, read and write;
- structures, cell arrays, sparse matrices, empty cells;
- writing character arrays.
If you have MATLAB or Octave, I'd like your help. 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.
Thanks to @EtherHelm for #21, and for doing half the work in it.
matlab: github.com/scigolib/matlab · pkg.go.dev
go get github.com/scigolib/matlab@v0.3.19 f, err := os.Open("data.mat") if err != nil { log.Fatal(err) } defer f.Close() mat, err := matlab.Open(f) // v5 or v7.3, detected from the header if err != nil { log.Fatal(err) } for _, v := range mat.Variables { fmt.Println(v.Name, v.DataType, v.Dimensions) } 
