Mutable, ordered JSON for Go — useful when decoding into structs is too rigid
and editing a map[string]any is too awkward.
djson keeps object keys in insertion order, provides path-based access and
typed conversions, preserves parsed number spelling, and still works with
encoding/json.
- Ordered objects. Parsing, mutation, cloning, and serialization preserve key order.
- Mutable tree. Read and update nested values without declaring a struct for every document shape.
- Convenient paths. Use paths such as
items/0/priceacross objects and arrays. - Exact parsed numbers. JSON numbers remain
json.Number, including their original decimal and exponent notation. - Strict boundaries. Invalid JSON returns an error; invalid mutations panic instead of leaving a partially valid tree.
- Standard-library integration.
DynamicJSONimplementsjson.Marshalerandjson.Unmarshaler. - Read-only sharing.
Freezemakes a complete tree safe for concurrent readers. - Modern iteration. Immediate and recursive traversal are available, along with Go iterator sequences.
go get github.com/gavriva/djsonpackage main
import (
"encoding/json"
"fmt"
"log"
"github.com/gavriva/djson"
)
func main() {
document, err := djson.Parse([]byte(`{
"request":{"id":"r-17"},
"items":[{"name":"pen","qty":2}]
}`))
if err != nil {
log.Fatal(err)
}
qty := document.GetInt("items/0/qty", 0)
document.Set("items/0/qty", qty+1)
document.Set("request/processed", true)
item := djson.NewMap()
item.SetKey("name", "paper")
item.SetKey("qty", 1)
document.Array("items").Append(item)
encoded, err := json.Marshal(document)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(encoded))
}Output:
{"request":{"id":"r-17","processed":true},"items":[{"name":"pen","qty":3},{"name":"paper","qty":1}]}Updating a key does not move it. Deleting and inserting it again places it at the end.
object := djson.NewMap()
object.SetKey("second", 2)
object.SetKey("first", 1)
object.SetKey("second", 20)
fmt.Println(string(object.JSONLine()))
// {"second":20,"first":1}This is useful for deterministic output, human-facing configuration, logs, hashes, and signatures over serialized JSON.
Most accessors accept slash-separated paths:
document.Set("users/0/name", "Ada")
name := document.GetString("users/0/name", "unknown")
value, exists := document.Fetch("users/0/name")When Set creates a missing level, canonical decimal segments such as 0 and
12 create arrays. Segments with leading zeroes, such as 007, remain object
keys.
A literal object key may contain /, ~, or even be empty. Use the raw-key
methods when a key must not be interpreted as a path:
document.SetKey("a/b", 1)
document.SetKey("", 2)
fmt.Println(document.GetKey("a/b"))
fmt.Println(document.HasKey(""))
document.DeleteKey("a/b")The raw-key family is GetKey, HasKey, SetKey, DeleteKey, and
NestedKey.
Scalar getters convert common JSON representations and return the supplied default when conversion is impossible:
workers := document.GetInt("config/workers", 4)
ratio := document.GetFloat("config/ratio", 1.0)
enabled := document.GetBool("config/enabled", false)
label := document.GetString("config/label", "default")
timeout := document.GetDuration("config/timeout", 5*time.Second)
createdAt := document.GetTime("created_at")ToInt, ToFloat, ToBool, and ToString provide the same conversions for
standalone values. GetAnySlice preserves every array element, while typed
slice getters fail loudly when an element cannot be converted. GetSlice and
Each are intended for arrays containing objects, arrays, or JSON null.
The tree uses a small canonical set of scalar representations:
| Input | Stored representation |
|---|---|
| Parsed JSON number | json.Number, original spelling preserved |
| Signed Go integer | int64 |
| Unsigned Go integer | uint64 |
float64 |
float64 |
float32 |
json.Number formatted with float32 precision |
time.Duration |
duration string, for example "250ms" |
time.Time |
UTC RFC3339 string |
NaN and infinities become JSON null because JSON has no representation for
them. An invalid json.Number is rejected at mutation time.
IsEqual compares numbers by value, so equivalent representations such as
json.Number("1.0"), int64(1), and float64(1) compare equal. Object order
does not affect structural equality.
Use EachPair for immediate values:
for key, value := range document.EachPair("request") {
fmt.Printf("%s = %v\n", key, value)
}Iterate offers callback-based immediate traversal, and Visit recursively
visits Get-compatible paths. Array keys are canonical decimal indexes in all
of these APIs.
Iteration has deterministic mutation rules:
- values appended after iteration starts are not visited;
- replacements inside the initial boundary are visible;
- object keys deleted before their turn are skipped;
- deleting or clearing an array during iteration panics because it would shift active indexes.
compact := document.JSONLine()
pretty := document.JSON()
compactWithError, err := json.Marshal(document)JSONLine returns compact JSON and JSON returns indented JSON. Both panic on
cycles or excessive nesting. Use json.Marshal or call MarshalJSON directly
when serialization failures must be returned as errors.
UnmarshalJSON atomically replaces the receiver and may switch it between an
object and an array. Top-level JSON null is a no-op.
DynamicJSON is not safe for concurrent access involving mutation. Freeze a
fully built document before sharing it between readers:
document.Freeze()
// Concurrent reads are now safe. Any mutation attempt panics.Freezing is recursive and irreversible. Clone returns a mutable deep copy and
does not copy the frozen state. If a source tree shares a child in several
places, each place receives an independent cloned child.
djson is aimed at frequently accessed small and medium dynamic documents. It
uses the Segment JSON tokenizer for parsing, a dedicated ordered serializer,
and lazy object indexes: small objects stay as compact ordered slices, while an
index is built when an object grows.
The repository includes benchmarks for parsing, path operations, serialization, cloning, equality, iteration, and flat and branching documents. Run them on the target machine instead of relying on portable-looking nanosecond claims.
Parse accepts a top-level object or array. Unlike encoding/json, it rejects
top-level scalars. It validates strict JSON grammar, preserves key order, and
copies the input; the complete source copy may remain referenced for the
lifetime of the parsed tree.
Nesting is limited by MaxNestingDepth:
Parsereturns an error when untrusted input is too deep;- recursive in-memory operations panic when the limit is exceeded;
- iterative mutation may build a deeper document, but a later recursive operation will reject it.
djson is an in-memory mutable tree. For very large documents or streaming
workloads, a token-based decoder is usually a better fit.
FromFile reads an object or array from disk. FromResponse and
FromResponse200 parse HTTP response bodies, close the body, understand
identity and gzip content encodings, and limit the decoded body to
MaxResponseBodySize.
The difference between the response helpers is status handling:
FromResponsemay return both a parsed non-200 response and a status error;FromResponse200returns a nil document for non-200 responses.
The package models JSON, not arbitrary Go object graphs:
Set,SetI, andAppendaccept JSON scalars, slices, arrays, string-keyed maps, and*DynamicJSONvalues;- custom
MarshalJSONandMarshalTextimplementations are not invoked; json.RawMessage, arbitrary pointers, structs, functions, channels, complex numbers, and maps with non-string keys are rejected;- inserting an existing
*DynamicJSONcan create a cycle; serialization and recursive traversal detect it instead of overflowing the stack.
Unsupported leaf values fail at the mutation boundary rather than much later during output. Recursive operations separately enforce the nesting limit.
go test ./...
go test -race ./...
go test -bench . -benchmem ./...