go-toml v2: TOML for Go, comments in and capitalized keys out
Go library for the TOML file format
At a glance
- What is it?
- A Go library for TOML v1.1.0 configuration files, modeled on encoding/json, with commented-out output and local date types. Its struct mapping has two edges worth knowing before you adopt it: the encoder writes capitalized keys back, and a zero time.Time disappears under omitempty.
- Who is it for?
- go-toml v2 earns a place in a Go service whose config file is edited by hand, because commented-out values and local date and time types have no encoding/json counterpart, and because a decode failure arrives with the offending line attached. Leave it out when a plain key/value file already works as JSON, and when the API has to be frozen today, since the parser and editing packages sit outside the backward compatibility guarantees.
- Can I use it commercially?
- Check first. The repository uses a licence we do not classify automatically, so read its LICENSE file before any commercial use.
- Is it still maintained?
- Yes. The repository last received commits 81 days ago.
- What is it written in?
- Mainly Go, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
Comments in config files, and dates with no timezone
Configuration files get read and rewritten by people, and that is the gap go-toml v2 fills. Its subject is TOML, and it states support for TOML v1.1.0, which buys it two things a plain Go struct encoder cannot do. The encoder can emit a document annotated with comments and commented-out values, so an optional block survives a read and rewrite cycle as text an operator can still read. And because TOML allows a date, time or date-time with no relation to a timezone or offset, three types named `LocalDate`, `LocalTime` and `LocalDateTime` convert to and from `time.Time` for that case.
The yardstick the project picks is Go's own `encoding/json`, which it says it tries to behave like, down to a field whose tag reads `omitempty` being left out when empty. What differs is the approach on both sides of the wire. JSON has no way to carry a comment, so nothing in `encoding/json` corresponds to a commented-out `cipher` line. JSON timestamps travel as quoted strings, so the local date and time case has no counterpart there either. Struct tags are a third difference in practice: the key is `toml`, not `json`, so annotations do not move between the two encoders. A team already running on a JSON config has no gap to close here. A team whose operators annotate their settings does.
Installing the v2 module and reading a first document
go.mod names the module `github.com/pelletier/go-toml/v2` and declares `go 1.21.0`, so the dependency resolves for any build on that language version or later. The major version suffix in the import path is not decoration: the default branch of the repository is named v2, and an import written without the suffix refers to a different module rather than to this code. The Import section of the README gives one line and stops there, with no separate installation command to copy.
import "github.com/pelletier/go-toml/v2"Decoding starts from an ordinary struct, with no tags required for plain names.
type MyConfig struct {
Version int
Name string
Tags []string
}A TOML document holding `version`, `name` and `tags` goes in through `toml.Unmarshal`, which takes the document as a byte slice and the struct as a pointer, and returns an error instead of panicking. Keys are lowercase in the document and capitalized on the struct, and the array lands as a Go slice of strings rather than as text.
Capitalized fields, lowercase keys, and the tag for hyphenated tables
Capitalized field names match lowercase document keys, so ordinary key names need no configuration. Hyphens are a different problem, because a Go field name cannot hold one, and a table called `my-variables` needs an explicit tag to bind.
doc := `
age = 45
fruits = ["apple", "pear"]
# these are very important!
[my-variables]
first = 1
second = 0.2
third = "abc"
# this is not so important.
[my-variables.b]
bfirst = 123
`The receiving struct nests an anonymous struct for the b table inside `Myvariables` and carries the tag on the outer field.
var Document struct {
Age int
Fruits []string
Myvariables struct {
First int
Second float64
Third string
B struct {
Bfirst int
}
} `toml:"my-variables"`
}Call `toml.Unmarshal([]byte(doc), &Document)` and check the error before reading fields. Two details are easy to miss here. The outer field is spelled `Myvariables` as one word starting with a capital, because the tag does the binding rather than the name. And the second entry is the one scalar in the example declared as a float64 rather than an integer, because the document writes a decimal there while `first = 1` goes into the `First int` field.
Marshal writes the capitalized names back into the file
Encoding is the mirror image of decoding, and the shape of the output is where the two stop lining up.
cfg := MyConfig{
Version: 2,
Name: "go-toml",
Tags: []string{"go", "toml"},
}
b, err := toml.Marshal(cfg)
if err != nil {
panic(err)
}
fmt.Println(string(b))
// Output:
// Version = 2
// Name = 'go-toml'
// Tags = ['go', 'toml']Hold those three output lines next to the note in the documentation that variables in a TOML document are lowercase. The generated document says `Version`, `Name` and `Tags`. Strings also come out as TOML literal strings in single quotes, so `Name = 'go-toml'` rather than the double-quoted form a person would type. Both are legal TOML and both decode again, but a file the encoder rewrites does not look like the file that went in, and a diff against an operator's version shows case and quote churn instead of the edit that was actually made.
Strict mode and the timestamp that never reaches the file
Two settings decide how loudly a bad configuration fails. Strict mode on `Decoder` returns an error when parts of the TOML document are not present in the target structure, which catches a key in the file that no field of the struct can hold, such as a port field written with the wrong letters; the method behind it is `Decoder.DisallowUnknownFields`. Note the direction of that check. What is described is a key in the document with nowhere to go, not a struct field the document never mentions, so a setting the file omits stays silent and the field keeps its zero value.
The encoder has its own trap, and it lives in the struct tags. A field whose tag reads `omitempty` is dropped when it is empty, and for `time.Time` the zero value counts as empty. A `created_at` or `updated_at` field that has never been assigned is therefore left out of the document entirely, with no comment and no placeholder, and a reader of the generated file cannot tell an unset timestamp from a missing key. Removing `omitempty` from the tag puts the value back, and the alternative of a pointer type such as `*time.Time` puts it back at the price of a nil check at every read site. No encoder option in the documentation changes this, so the tag or the field type is the only lever.
DecodeError points a marker at the failing column
Most decoding failures arrive as `DecodeError`, which renders the document with line numbers and a marker under the offending value.
1| [server]
2| path = 100
| ~~~ cannot decode TOML integer into struct field toml_test.Server.Path of type string
3| port = 50The message names the Go type it expected, so the fix is readable without attaching a debugger: an integer literal went into a string field, and `Server.Path` is where to look. Two things about this sample deserve to be said plainly rather than admired. The struct named in the text is `toml_test.Server.Path`, which belongs to the project's own test package, so the output is illustrative rather than captured from an application. And the gutter is sized for single-digit line numbers, because the sample stops at line 3; the documentation offers no sample with a wider gutter, so the marker layout in a long document is not shown anywhere.
tomll, tomljson and jsontoml ship without any usage text
Three command line tools sit at the top of the repository, and a Dockerfile copies them into an image.
FROM scratch
ENV PATH "$PATH:/bin"
ARG TARGETPLATFORM
COPY $TARGETPLATFORM/tomll /bin/tomll
COPY $TARGETPLATFORM/tomljson /bin/tomljson
COPY $TARGETPLATFORM/jsontoml /bin/jsontomlNo compile step appears in those lines. Each COPY expects a binary that already exists in a directory named after `TARGETPLATFORM`, which pairs with the `.goreleaser.yaml` file at the top level. Two details reward a close reading. The base image is `scratch`, so nothing sets a runtime PATH, and Docker expands `$PATH` in an ENV line while building rather than when a container starts, so the value baked into the image is whatever the builder happened to have. And the tools sit at fixed absolute paths such as `/bin/tomll` regardless. The names read as a TOML linter plus two converters between TOML and JSON, but the README documents no flags, no usage text and no build command for any of them, which leaves a user of these three tools with their names alone.
The last push was on 2026-08-03, after tags v2.4.3 on 2026-07-05, v2.4.2 on 2026-06-24 and v2.4.1 on 2026-06-22. The LICENSE file holds a custom licence that GitHub could not classify, and the documentation does not restate its terms, so read that file before copying this code into a product you ship closed.
The performance paragraph points at a section that is not there
A Performance section says the library "favors usability" and was "written with performance in mind", adds that "most operations should not be shockingly slow", and ends with a link to `#benchmarks`. No Benchmarks heading follows anywhere in the file. What the repository carries instead is a `benchmark/` directory, so the measurements a reader is pointed at live outside the documentation, and the only performance claim on the page is the absence of the table it cites. The comparison the project draws is with Go's `encoding/json` rather than with another TOML parser.
The Unstable API section is cut the same way. It lists four things: the `Parser` for iterative parsing at the AST level, the `unstable.Marshaler` and `unstable.Unmarshaler` interfaces, `unstable.RawMessage` modeled on `json.RawMessage`, and a document editing package in `unstable/edit`. The description of that last package stops mid-word at "modifies TOML documents in pla", so what it can do is documented nowhere in the README, and the interfaces need opting in through `Encoder.EnableMarshalerInterface` and `Decoder.EnableUnmarshalerInterface` before a type can implement them. The section also states plainly that this API does not yet follow the backward compatibility guarantees of the library, which is the upgrade cost to weigh before building on `Parser` or `unstable/edit`: nothing promises those packages across v2 updates.
The rest of the top level describes a conformance posture rather than a library surface: `toml.abnf` for the grammar, `toml_testgen_test.go` for generated tests, `ossfuzz/` and `fuzz_test.go` for fuzzing, `capability_baseline.txt` beside `caps.sh` for capability checks, and `ci.sh` beside `test-go-versions.sh` for the build matrix.
Editorial conclusion
go-toml v2 earns a place in a Go service whose config file is edited by hand, because commented-out values and local date and time types have no encoding/json counterpart, and because a decode failure arrives with the offending line attached. Leave it out when a plain key/value file already works as JSON, and when the API has to be frozen today, since the parser and editing packages sit outside the backward compatibility guarantees. Read the encoder output for your own structs before trusting a generated file.
Frequently asked questions
Which import path does go-toml v2 use?
The module is github.com/pelletier/go-toml/v2, and the import line carries the /v2 suffix because the major version is 2. The go.mod file declares go 1.21.0, and the default branch of the repository is v2.
Does go-toml v2 write zero timestamps into a TOML file?
For time.Time the zero value counts as empty, so a field whose tag reads omitempty, such as created_at or updated_at, is left out of the encoded document. Dropping omitempty or switching the field to a pointer type such as *time.Time puts the value back.
How does go-toml v2 report a type mismatch inside a config file?
Most decoding errors come back as DecodeError, which prints the document with line numbers and a marker under the offending value. The message names the field and the type it wanted, for example cannot decode TOML integer into struct field toml_test.Server.Path of type string.
Can go-toml v2 catch a misspelled key in a config file?
Strict mode on Decoder returns an error when parts of the document are not present in the target structure, which covers a key the struct has no field for. The documented behavior runs in one direction only: a struct field the document never mentions is not described as an error.
What are the tomll, tomljson and jsontoml programs in the repository?
They are copied into /bin by the repository Dockerfile, which starts FROM scratch and expects prebuilt binaries in a directory named after TARGETPLATFORM. The README documents no flags, usage text or build command for them, so the binary names are all the documentation provides.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/pelletier-go-toml)Was this useful?