avro

package module
v1.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 6, 2026 License: MIT Imports: 25 Imported by: 1

README

avro

Go Reference

Encode and decode Avro binary data.

This project aims to be the "best" Avro encoder/decoder in the Go ecosystem by:

  • Keeping the API tight but comprehensive
  • Combining features that exist in only one of linkedin/goavro or hamba/avro
  • Being safe above all, while being fast with unsafe specialization functions internally
  • Running round after round of AI audits to shake out any bug / DoS / huge alloc that exists
  • Being documented thoroughly for both human and AI users

For the "why" of this project, see the Why section.

Index

Quick Start

package main

import (
	"fmt"
	"log"

	"github.com/twmb/avro"
)

var schema = avro.MustParse(`{
    "type": "record",
    "name": "User",
    "fields": [
        {"name": "name", "type": "string"},
        {"name": "age",  "type": "int"}
    ]
}`)

type User struct {
	Name string `avro:"name"`
	Age  int    `avro:"age"`
}

func main() {
	data, err := schema.Encode(&User{Name: "Alice", Age: 30})
	if err != nil {
		log.Fatal(err)
	}

	var u User
	_, err = schema.Decode(data, &u)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(u) // {Alice 30}
}

Type Mapping

All types can decode into any.

Avro Type Encode Decode
null anything nillable zeroes the target
boolean bool bool
int, long, float, double numeric numeric
string string []byte TextAppender TextMarshaler string []byte TextUnmarshaler
bytes bytes-like bytes-like
enum string integer TextAppender TextMarshaler string integer TextUnmarshaler
fixed bytes-like bytes-like
array slice or [N]array slice or [N]array
map map[string]T map[string]T
union *T, tagged-union map, or the matched branch *T or the matched branch
record struct or map[string]any struct or map[string]any

Shorthands used above: numeric = int int8-int64 uint uint8-uint64 float32 float64 json.Number. integer = int int8-int64 uint uint8-uint64. bytes-like = []byte [N]byte string. TextAppender / TextMarshaler / TextUnmarshaler are the standard encoding interfaces.

Numeric types accept any numeric Go type, but coercion has precision rules: integers require a whole number in range, and floats round silently. See Encode/decode behavior contract. Lengths must match: decoding bytes into [N]byte, or encoding or decoding any value as fixed, requires the byte length to equal N, and [N]array requires exactly N elements.

Decoding into any yields the natural Go type: int32 and int64 for int and long, float32 and float64 for float and double, []any for arrays, and map[string]any for maps and records. Logical types use the Go types in Logical Types. A null (e.g. a union's null branch) decodes to the target's zero value, replacing any prior contents. Use *T if you need to tell null from zero.

Struct Tags

Struct fields are matched to Avro record fields by name. Use the avro struct tag to control the mapping:

type Example struct {
    Name    string  `avro:"name"`          // maps to Avro field "name"
    Ignored int     `avro:"-"`             // excluded from encoding/decoding
    Inner   Nested  `avro:",inline"`       // inline Nested's fields into this record
    Value   int     `avro:"val,omitzero"`  // encode zero value as Avro default
}

The tag format is:

avro:"[name][,option][,option]..."

The name portion maps the struct field to the Avro field with that name. If empty, the Go field name is used as-is. A tag of "-" excludes the field entirely.

Supported options:

  • inline: flatten a nested struct's fields into the parent record, as if they were declared directly on the parent. The field must be a struct or pointer to struct. This works like anonymous (embedded) struct fields, but for named fields. When using inline, the name portion of the tag must be empty and no other tag options are allowed, since the flattened struct has no field of its own for default=, alias=, or a logical-type tag to apply to. Put those options on the inlined struct's fields directly.

  • omitzero: when encoding, a zero value (or a field whose IsZero() bool method returns true) is encoded as if its key were omitted from a map[string]any: the field's schema default is used. A nullable field with no default encodes null; a non-nullable field with no default keeps its zero value, since there is nothing to fill with. This is useful for optional fields in ["null", T] / ["T", "null"] unions or fields with explicit defaults.

Embedded (anonymous) struct fields are automatically inlined: their fields are promoted into the parent as if declared directly. To prevent inlining an embedded struct, give it an explicit name tag:

type Parent struct {
    Nested                    // inlined: Nested's fields are promoted
    Other  Aux `avro:"other"` // not inlined: treated as a single field
}

When multiple fields resolve to the same Avro field name, a tagged field wins over an untagged one at any depth; among fields with the same tagged status, the shallowest field wins. Two fields that resolve to the same name at the same depth with the same tagged status are ambiguous (Go itself makes such a field reference a compile error). We error rather than silently selecting one: SchemaFor rejects the type, while encode and decode reject only when the schema actually has a field with the ambiguous name.

By default, decoding into a struct that has no field for some record field is an error. Pass SkipUnknown() to skip the fields your struct does not map instead. This is decode only; encoding a partial struct still errors, because the missing fields would go out as zero values.

Schema Inference

SchemaFor infers an Avro schema from a Go struct type, using the same struct tags as encoding/decoding:

type User struct {
    Name      string     `avro:"name"`
    Age       int32      `avro:"age,default=18"`
    Email     *string    `avro:"email"`
    CreatedAt time.Time  `avro:"created_at"`
}

schema := avro.MustSchemaFor[User](avro.WithNamespace("com.example"))

This produces the equivalent of:

{
  "type": "record",
  "name": "User",
  "namespace": "com.example",
  "fields": [
    {"name": "name", "type": "string"},
    {"name": "age", "type": "int", "default": 18},
    {"name": "email", "type": ["null", "string"]},
    {"name": "created_at", "type": {"type": "long", "logicalType": "timestamp-millis"}}
  ]
}

Go types map to Avro types automatically: *T becomes a ["null", T] union with "default": null (backward-compatible by default), [N]byte becomes a fixed type named after the Go type (or fixed_N for unnamed arrays), time.Time becomes timestamp-millis, and so on (see Type Mapping).

Additional tag options for schema inference:

Tag Example Description
default= avro:",default=0" Default value (must be last; scalars only)
alias= avro:",alias=old" Field alias for schema evolution (repeatable, or alias=[a,b])
type-alias= avro:",type-alias=old" Alias for the field's named type (record, enum, or fixed; repeatable, or type-alias=[a,b])
timestamp-micros avro:",timestamp-micros" Override logical type
decimal(p,s) avro:",decimal(10,2)" Decimal logical type (requires *big.Rat or big.Rat)
uuid avro:",uuid" UUID logical type (requires Go string, [16]byte, or a text marshaler type)
date avro:",date" Date logical type (requires time.Time, time.Duration, or an int8/16/32/uint8/uint16)

alias and type-alias serve different purposes in schema evolution. alias adds an alias to the field, letting a writer field with a different name match this reader field. type-alias adds an alias to the named type (record, enum, or fixed) that the field references, letting a writer type with a different name match the reader type. The alias is applied to the innermost named type, walking through pointers, slices, and maps:

type FieldSummary struct {
    ContainsNull bool    `avro:"contains_null"`
    ContainsNaN  *bool   `avro:"contains_nan"`
}

type ManifestFile struct {
    // alias=old_partitions: match writer field named "old_partitions"
    // type-alias=r508:      match writer record type named "r508" for FieldSummary
    Partitions *[]FieldSummary `avro:"partitions,type-alias=r508"`
}

Options:

  • WithNamespace(ns) sets the Avro namespace for the record.
  • WithName(name) overrides the record name (defaults to the Go struct name).

Schema Introspection

Schema.Root() returns a SchemaNode tree describing the parsed schema, including field types, logical types, doc strings, and custom properties:

schema, _ := avro.Parse(schemaJSON)
root := schema.Root()

for _, f := range root.Fields {
    fmt.Printf("field %s: type=%s\n", f.Name, f.Type.Type)
    if cn, ok := f.Props["connect.name"].(string); ok {
        fmt.Printf("  kafka connect type: %s\n", cn)
    }
}

A named type that appears more than once is a full definition the first time and a bare name reference afterward. SchemaNode.ExpandReferences() returns a copy of the tree with every reference replaced by its definition, so you can walk the tree without resolving names yourself. Recursive types stay as references.

SchemaNode can also be used to build schemas programmatically:

node := &avro.SchemaNode{
    Type: "record",
    Name: "User",
    Fields: []avro.SchemaField{
        {Name: "name", Type: avro.SchemaNode{Type: "string"}},
        {Name: "age", Type: avro.SchemaNode{Type: "int"}, Default: 18},
    },
}
schema, err := node.Schema()

Reserved attribute names (type, name, items, ...) match only their exact lowercase spelling, as in the Java, Python (fastavro), and goavro implementations. A key differing from a reserved name only by letter case ("ITEMS", "Namespace") is an ordinary custom property, preserved in Props. Note that hamba/avro matches these keys case-insensitively, so a schema that parsed there but fails here has a miscased reserved key. A miscased structural key fails at parse time: {"type":"array","Items":"int"} errors with "array is missing items schema", so fix the casing. A miscased non-structural key ("Doc", "Aliases") simply becomes a custom property instead of binding the attribute.

Logical Types

Logical types decode to their natural Go equivalents:

Logical Type Avro Type Encode Decode
date int time.Time, RFC 3339 or YYYY-MM-DD string, or int time.Time (UTC)
time-millis int time.Duration, time.Time (lossy), or int time.Duration or time.Time (lossy)
time-micros long time.Duration, time.Time (lossy), or int time.Duration or time.Time (lossy)
timestamp-millis long time.Time, RFC 3339 string, or int time.Time (UTC)
timestamp-micros long time.Time, RFC 3339 string, or int time.Time (UTC)
timestamp-nanos long time.Time, RFC 3339 string, or int time.Time (UTC)
local-timestamp-millis long time.Time, RFC 3339 string, or int time.Time (UTC)
local-timestamp-micros long time.Time, RFC 3339 string, or int time.Time (UTC)
local-timestamp-nanos long time.Time, RFC 3339 string, or int time.Time (UTC)
uuid (string) string [16]byte or string string into any; [16]byte or string into typed target
uuid (fixed(16)) fixed(16) [16]byte, []byte, or hex-dash string [16]byte into any or [16]byte target; string into string target
decimal bytes or fixed *big.Rat, float64, numeric string, json.Number, or underlying type *big.Rat, float64/float32, numeric string, json.Number, or underlying type
big-decimal bytes *big.Rat, float64, numeric string, json.Number *big.Rat, float64/float32, numeric string, json.Number
duration fixed(12) avro.Duration or underlying type avro.Duration or underlying type

When encoding, timestamp and date fields accept RFC 3339 strings, and decimal fields accept float64 and numeric strings (e.g. "3.14"). Values that do not match the expected format fall through to the underlying type's encoder, which returns an error.

Encoding a time.Time as time-millis or time-micros is lossy. These logical types are time-of-day only, so the wire bytes cannot carry a date or zone. On encode, only the wall-clock fields (hour, minute, second, nanosecond) are written, and the year, month, day, and location are dropped. On decode into a time.Time, the wire value is placed at the Unix epoch (1970-01-01 UTC) plus the time-of-day. A round-trip through time.Time therefore keeps the time-of-day but resets the date and zone: 2024-01-15 12:34:56 PST comes back as 1970-01-01 12:34:56 UTC. If you need the date, use timestamp-millis or timestamp-micros, or convert to and from time.Duration yourself. A time.Duration round-trips exactly when its nanoseconds are a whole multiple of the schema's resolution (a millisecond for time-millis, a microsecond for time-micros); anything finer is truncated toward zero on encode.

big-decimal carries no schema-level precision or scale; scale is derived per value, and rationals with no finite decimal expansion (e.g. big.NewRat(1, 3)) return an error.

Unknown logical types are ignored per the Avro spec, and the underlying type is used as-is.

Schema Evolution

Avro data is always written with a specific schema, the writer schema. When you read that data later, your application may expect a different schema, the reader schema. You may have added a field, removed one, or widened a type from int to long.

Resolve takes the writer and reader schemas and returns a new schema that decodes data in the old wire format and produces values in the reader's layout:

  • Fields in the reader but not the writer are filled from defaults.
  • Fields in the writer but not the reader are skipped.
  • Fields that exist in both are matched by name (or alias) and decoded, with type promotion applied where needed (e.g. int to long).
Example

Suppose v1 of your application wrote User records with just a name:

var writerSchema = avro.MustParse(`{
    "type": "record", "name": "User",
    "fields": [
        {"name": "name", "type": "string"}
    ]
}`)

In v2 you added an email field with a default:

var readerSchema = avro.MustParse(`{
    "type": "record", "name": "User",
    "fields": [
        {"name": "name",  "type": "string"},
        {"name": "email", "type": "string", "default": ""}
    ]
}`)

type User struct {
    Name  string `avro:"name"`
    Email string `avro:"email"`
}

To read old v1 data with your v2 struct, resolve the two schemas:

resolved, err := avro.Resolve(writerSchema, readerSchema)

var u User
_, err = resolved.Decode(v1Data, &u)
// u == User{Name: "Alice", Email: ""}

The following type promotions are supported:

Writer Reader
int long, float, double
long float, double
float double
string bytes
bytes string

CheckCompatibility checks whether two schemas are compatible without building a resolved schema. The direction you check depends on the guarantee you need:

// Backward: new schema can read old data.
avro.CheckCompatibility(oldSchema, newSchema)

// Forward: old schema can read new data.
avro.CheckCompatibility(newSchema, oldSchema)

// Full: check both directions.
avro.CheckCompatibility(oldSchema, newSchema)
avro.CheckCompatibility(newSchema, oldSchema)

Schema Cache

When working with a schema registry, schemas often reference types defined in other schemas. SchemaCache accumulates named types across multiple Parse calls so they can be resolved:

var cache avro.SchemaCache

// Parse the referenced schema first; order matters.
_, err := cache.Parse(`{
    "type": "record",
    "name": "Address",
    "fields": [{"name": "city", "type": "string"}]
}`)

// Now parse a schema that references Address.
schema, err := cache.Parse(`{
    "type": "record",
    "name": "User",
    "fields": [
        {"name": "name",    "type": "string"},
        {"name": "address", "type": "Address"}
    ]
}`)

Parsing the same schema string multiple times returns the cached result, so diamond dependencies need no deduplication on your side. The returned *Schema is independent of the cache and safe to use concurrently.

Custom Types

Register custom Go type conversions with NewCustomType for type-safe primitive conversions, or CustomType for advanced cases:

type Money struct {
    Cents    int64
    Currency string
}

moneyType := avro.NewCustomType[Money, int64]("money",
    func(m Money, _ *avro.SchemaNode) (int64, error) { return m.Cents, nil },
    func(c int64, _ *avro.SchemaNode) (Money, error) {
        return Money{Cents: c, Currency: "USD"}, nil
    },
)

schema := avro.MustParse(moneySchema, moneyType)

// Encode and decode: Money fields are converted automatically.
data, _ := schema.Encode(&order)
var out Order
schema.Decode(data, &out) // out.Price is Money{Cents: 500, ...}

// Works with SchemaFor too.
schema = avro.MustSchemaFor[Order](moneyType)

A matching custom type replaces the built-in logical type deserializer. Decode callbacks receive raw Avro-native values (int64 for long, int32 for int, etc.). A nil Decode suppresses the built-in handler with zero overhead, producing raw values directly:

// Decode timestamps as raw int64 instead of time.Time.
schema := avro.MustParse(raw, avro.CustomType{
    LogicalType: "timestamp-millis",
    AvroType:    "long",
})

For property-based dispatch (e.g. Kafka Connect / Debezium types), use empty matching criteria with ErrSkipCustomType:

avro.CustomType{
    Decode: func(v any, node *avro.SchemaNode) (any, error) {
        name, _ := node.Props["connect.name"].(string)
        switch name {
        case "io.debezium.time.Timestamp":
            return time.UnixMilli(v.(int64)).UTC(), nil
        default:
            return nil, avro.ErrSkipCustomType
        }
    },
}

Type name constants

The atype sub-package exports constants for Avro primitive type names, complex type names, logical type names, and field sort orders: the string values used in SchemaNode, SchemaField, and CustomType:

import (
    "github.com/twmb/avro"
    "github.com/twmb/avro/atype"
)

node := avro.SchemaNode{
    Type:        atype.Long,
    LogicalType: atype.TimestampMicros,
}

ct := avro.CustomType{LogicalType: atype.Decimal}

They are untyped string constants and can be used anywhere a string is expected, and they catch typos (atype.TimestampMicros vs "timestam-micros") at compile time.

Object Container Files

The ocf sub-package reads and writes Avro Object Container Files, self-describing binary files that embed the schema in the header and store data in compressed blocks.

Writing
var schema = avro.MustParse(`{
    "type": "record",
    "name": "User",
    "fields": [
        {"name": "name", "type": "string"},
        {"name": "age",  "type": "int"}
    ]
}`)

f, _ := os.Create("users.avro")
w, err := ocf.NewWriter(f, schema, ocf.WithCodec(ocf.SnappyCodec()))
if err != nil {
    log.Fatal(err)
}
w.Encode(&User{Name: "Alice", Age: 30})
w.Encode(&User{Name: "Bob", Age: 25})
w.Close()
f.Close()
Reading
f, _ := os.Open("users.avro")
r, err := ocf.NewReader(f)
if err != nil {
    log.Fatal(err)
}
defer r.Close()
for {
    var u User
    err := r.Decode(&u)
    if err == io.EOF {
        break
    }
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(u)
}

The reader's Schema() method returns the schema parsed from the file header, which you can pass as the writer schema to Resolve. Alternatively, pass WithReaderSchema and the reader resolves for you. WithDecodeOpts passes decode options such as TaggedUnions() through to each Decode.

Codecs

Built-in codecs: null (default, no compression), deflate (DeflateCodec), snappy (SnappyCodec), and zstandard (ZstdCodec). Custom codecs can be provided via the Codec interface.

Memory bounds. The reader caps both the compressed block size it reads off the wire (WithMaxBlockBytes) and the size a block decompresses to (WithMaxDecompressedBlockBytes), each 64 MiB by default. The decompressed cap is applied before allocating, through the BoundedDecompressor interface, which all built-in codecs implement. A custom codec that does not implement it decompresses unbounded, so for untrusted input supply one that bounds itself.

Appending

NewAppendWriter opens an existing OCF for appending. It reads the header to recover the schema, codec, and sync marker, then seeks to the end.

JSON Encoding

EncodeJSON is a schema-aware JSON serializer. By default it produces standard JSON with bare union values and \uXXXX-encoded bytes:

// Standard JSON (default): bare unions
jsonBytes, err := schema.EncodeJSON(&user)
// {"name":"Alice","email":"a@b.com"}

// Avro JSON: unions wrapped as {"type_name": value}
jsonBytes, err = schema.EncodeJSON(&user, avro.TaggedUnions())
// {"name":"Alice","email":{"string":"a@b.com"}}

The tagged form is the Avro JSON spec's representation and what Java and other Avro tools require; the bare default is plainer JSON for non-Avro consumers.

DecodeJSON accepts both formats (tagged and bare unions) and all NaN/Infinity conventions:

var user User
err = schema.DecodeJSON(jsonBytes, &user)

Decode and DecodeJSON also accept TaggedUnions() to wrap union values when decoding into *any:

var native any
schema.Decode(binary, &native, avro.TaggedUnions())
// native["email"] is map[string]any{"string": "a@b.com"}

Encode and DecodeJSON accept both tagged and bare union input, so tagged union output from Decode can round-trip through Encode directly.

Pass TagLogicalTypes() with TaggedUnions() to qualify union branch names with their logical type (e.g. "long.timestamp-millis" instead of "long"), matching the linkedin/goavro naming convention.

NaN and Infinity float values are encoded as "NaN", "Infinity", "-Infinity" strings by default (Java Avro convention). Pass LinkedinFloats() for the linkedin/goavro convention (null for NaN, ±1e999 for Infinity).

Single Object Encoding

For sending self-describing values over the wire (as opposed to files, where OCF is preferred), use Single Object Encoding. Each message is a 2-byte magic header, an 8-byte CRC-64-AVRO fingerprint, and the Avro binary payload.

// Encode with fingerprint header
data, err := schema.AppendSingleObject(nil, &user)

// Decode (schema known)
_, err = schema.DecodeSingleObject(data, &user)

// Decode (schema unknown): extract fingerprint, look up schema
fp, payload, err := avro.SingleObjectFingerprint(data)
schema := registry.Lookup(fp) // your schema registry
_, err = schema.Decode(payload, &user)

Fingerprinting

Canonical returns the Parsing Canonical Form of a schema: a deterministic JSON representation stripped of doc, aliases, defaults, and other non-essential attributes. Use it for schema comparison and fingerprinting.

canonical := schema.Canonical() // []byte

// CRC-64-AVRO (Rabin), the Avro-standard fingerprint
fp := schema.Fingerprint(avro.NewRabin())

// SHA-256, common for cross-language registries
fp256 := schema.Fingerprint(sha256.New())

Errors

Encode and decode errors can be inspected with errors.As:

  • *SemanticError: type mismatch between Go and Avro (includes a dotted field path for nested records, e.g. "address.zip").
  • *ShortBufferError: input truncated mid-value.
  • *CompatibilityError: schema evolution incompatibility (from Resolve or CheckCompatibility).

Performance

Struct field access uses unsafe pointer arithmetic (similar to encoding/json v2) to avoid reflect.Value overhead on every encode/decode. All schemas, type mappings, and codec state are cached after first use so repeated operations pay no extra allocation cost.

Decoded strings and byte slices are copied out of the input by default. Pass AliasInput() to Decode to have them point into the input instead, which skips the copy entirely. You must not modify the input or the decoded values afterward, and one aliased field keeps the whole input alive, so only use it when you own the buffer and do not reuse it.

Encode/decode behavior contract

The encoder and decoder are mostly symmetric: any Go shape the encoder accepts as input is a Go shape the decoder accepts as a target, and an encode then decode round-trip through the same Go type yields the same value. The cases below are deliberate exceptions.

Round-trips that lose data
  • time.Time as time-millis / time-micros keeps only the time-of-day; date and zone are dropped. The decoded time.Time sits at the Unix epoch with the original time-of-day. Use timestamp-* to keep the full instant.
  • time.Time as date keeps only the UTC date; time-of-day and zone are dropped.
  • big-decimal trailing-zero scale is not preserved: scale is derived per value, and big.Rat cannot carry the distinction.
Numbers
  • Precision follows the reader schema. A float/double schema rounds silently into a Go float (overflowing to ±Inf on encode), but decoding one into a Go integer still requires a whole number in range. An int/long schema never loses precision silently: decoding into a Go type that cannot hold the value exactly errors. For exact large-integer round-trips keep the schema long with an int64 target; evolving to double opts into rounding.
  • Union branch is chosen by the Go type, not the value. A Go int always selects a union's long branch, never int, even when the value would fit, because dispatch keys off the static type, keeping wire size deterministic. Force the int branch with an int32 value or map[string]any{"int": v}.
  • Encoding a json.Number into an int/long works even in fractional or exponent form, as long as the value is whole. json.Number("9.5e17") succeeds (it is exactly 950000000000000000). Note that the same literal as a schema default ("default":9.5e17) does not load in Java; use the plain integer form if you publish schemas to Java consumers.

Why

I had efficient encode-side code lying around for two years. I'd written it one way, started rewriting it another, got most of the way through, and decided it wasn't worth the effort. hamba/avro was around by then and really good, so I stopped.

When I started using LLMs in January 2026, I figured I'd touch up all the old projects I had lying around. I did it for this one, and coincidentally, hamba/avro got archived right around then. I wanted one library that did it all: hamba/avro missed things linkedin/goavro had, and vice versa. Here we are.

Documentation

Overview

Package avro encodes and decodes Avro specification data.

Parse an Avro JSON schema with Parse (or MustParse for package-level vars), then call Schema.Encode / Schema.Decode for binary encoding, or Schema.EncodeJSON / Schema.DecodeJSON for JSON encoding. Use SchemaFor to infer a schema from a Go struct type, or Schema.Root to inspect a parsed schema's structure.

Basic usage

schema := avro.MustParse(`{
    "type": "record",
    "name": "User",
    "fields": [
        {"name": "name", "type": "string"},
        {"name": "age",  "type": "int"}
    ]
}`)

type User struct {
    Name string `avro:"name"`
    Age  int    `avro:"age"`
}

// Encode
data, err := schema.Encode(&User{Name: "Alice", Age: 30})

// Decode
var u User
_, err = schema.Decode(data, &u)

JSON encoding

Schema.EncodeJSON is schema-aware: we handle bytes, unions, and NaN/Infinity floats correctly. Use it rather than a generic JSON encoder when serializing decoded Avro data. Options control the output format: TaggedUnions for Avro JSON union wrappers ({"type": value}), TagLogicalTypes for qualified branch names, and LinkedinFloats for the goavro NaN/Infinity convention.

Encoding from JSON input

You can encode generically-decoded JSON data (map[string]any with float64 numbers and string timestamps) directly. We fill missing map keys from schema defaults. encoding/json.Number is accepted for numeric Avro types only: string, bytes, fixed, and enum reject it, so use a Go string or []byte for those. Timestamp fields accept RFC 3339 strings, and string fields accept encoding.TextAppender and encoding.TextMarshaler implementations (with encoding.TextUnmarshaler on decode).

Schema evolution

Avro data is always written with a specific schema, the "writer schema". When you read it later, your application may expect a different "reader schema": you may have added a field, removed one, or widened an int to a long.

Resolve takes the writer and reader schemas and returns a schema that decodes data in the writer's wire format into the reader's layout:

  • We fill reader-only fields from defaults.
  • We skip writer-only fields.
  • We match fields in both by name (or alias) and decode them, promoting types where needed (e.g. int to long).

You usually get the writer schema from the data itself: an OCF file header embeds it, and schema registries store it by ID or fingerprint.

As a concrete example, suppose v1 of your application wrote User records with just a name:

var writerSchema = avro.MustParse(`{
    "type": "record", "name": "User",
    "fields": [
        {"name": "name", "type": "string"}
    ]
}`)

In v2, you added an email field with a default:

var readerSchema = avro.MustParse(`{
    "type": "record", "name": "User",
    "fields": [
        {"name": "name",  "type": "string"},
        {"name": "email", "type": "string", "default": ""}
    ]
}`)

type User struct {
    Name  string `avro:"name"`
    Email string `avro:"email"`
}

To read old v1 data with your v2 struct, resolve the two schemas:

resolved, err := avro.Resolve(writerSchema, readerSchema)

// Decode v1 data: "email" is absent in the old data, so it gets
// the reader default ("").
var u User
_, err = resolved.Decode(v1Data, &u)
// u == User{Name: "Alice", Email: ""}

If you only want to check whether two schemas are compatible, use CheckCompatibility.

A null union branch decodes to the target's Go zero value, replacing any prior value. Use *T if you need to tell null from zero.

Precision follows the reader schema. A float or double schema rounds silently on both encode and decode, and a finite value out of range becomes ±Inf on the wire. An int, long, bytes, or string schema never loses precision silently: decoding into a Go type that cannot hold the value exactly, such as a long above 2^53 into a float64, is an error. If you need large integers to round-trip exactly, keep the reader schema long and decode into an int64.

Struct tags

Use the "avro" struct tag to control field mapping and schema inference. The format is avro:"[name][,option]..." where the name maps the Go field to the Avro field name (empty = use Go field name, "-" = exclude).

Encoding/decoding options:

avro:"name"           // map to Avro field "name"
avro:"-"              // exclude field
avro:",inline"        // flatten nested struct fields into parent record
avro:",omitzero"      // encode a zero value as the field's default (or null)

Schema inference options (used by SchemaFor):

avro:",default=value"       // field default; last option, scalars only
avro:",alias=old_name"      // field alias; repeatable, or alias=[a,b]
avro:",type-alias=old_name" // named type alias; same two spellings
avro:",timestamp-micros"    // logical type; see the list below
avro:",decimal(10,2)"       // decimal logical type, precision and scale
avro:",uuid"                // UUID logical type

The logical types you can override with are timestamp-millis, timestamp-micros, timestamp-nanos, date, time-millis, and time-micros.

The alias tag adds an alias to the field itself. The type-alias tag adds an alias to the named type (record, enum, or fixed) that the field references; we walk through pointers, slices, and maps to find it. You need it when a writer schema uses a different name for the same type: a legacy schema naming a record "r508" instead of "FieldSummary".

When you encode a map[string]any as a record, we fill missing keys from the schema's defaults. A ["null", T] field with no default has an implicit null default, so a missing key there fills null rather than erroring. The omitzero tag applies the same fill to a struct's zero-valued fields, and to fields whose IsZero() method reports true: a zero value encodes the field's default, or null for a nullable field with no default. A non-nullable field with no default has nothing to fill with, so it encodes the zero value itself. Note that omitzero differs from map fill for a [T, "null"] union with no default: a union default must match the first branch, so no null default can exist there, and omitzero encodes null where map fill errors on the missing key.

We inline embedded (anonymous) struct fields automatically; an explicit name tag prevents it. When several fields resolve to one name, a tagged field wins over an untagged one at any depth, and among equally tagged fields the shallowest wins. Two fields at the same depth with the same tagged status are ambiguous, and we error rather than pick one. SchemaFor rejects the type outright, while encode and decode reject only when the schema actually has a field with that name.

Custom types

CustomType registers custom Go type conversions for logical types, domain types, or to replace our built-in behavior. A matching custom type replaces our built-in logical type deserializer: your Decode callback receives raw Avro-native values, not enriched types like time.Time. A CustomType with nil Decode suppresses the built-in handler with zero overhead, producing raw values directly. Use NewCustomType for type-safe primitive conversions, or the CustomType struct directly for complex cases (records, fixed types, property-based dispatch). You register custom types per-schema via SchemaOpt.

Parsing options

Parse and SchemaCache.Parse accept WithLaxNames to allow non-standard characters in type and field names.

Errors

You can inspect encode and decode errors with errors.As:

Other features

The README's "Encode/decode behavior contract" section lists the intentional asymmetries between the encoder and the decoder.

Example
package main

import (
	"fmt"
	"log"

	"github.com/twmb/avro"
)

func main() {
	schema := avro.MustParse(`{
		"type": "record",
		"name": "User",
		"fields": [
			{"name": "name", "type": "string"},
			{"name": "age",  "type": "int"}
		]
	}`)

	type User struct {
		Name string `avro:"name"`
		Age  int32  `avro:"age"`
	}

	data, err := schema.Encode(&User{Name: "Alice", Age: 30})
	if err != nil {
		log.Fatal(err)
	}

	var u User
	if _, err := schema.Decode(data, &u); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%s is %d\n", u.Name, u.Age)
}
Output:
Alice is 30

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrSkipCustomType = errors.New("avro: skip custom type")

ErrSkipCustomType is returned from a CustomType Encode or Decode function to say this custom type does not handle the value. We fall through to the next matching custom type, or to built-in behavior.

Functions

func CheckCompatibility

func CheckCompatibility(writer, reader *Schema) error

CheckCompatibility reports whether data written with the writer schema can be read by the reader schema. We return nil on success, or a *CompatibilityError describing the first incompatibility.

See Resolve for a note on argument order.

func NewRabin

func NewRabin() hash.Hash64

NewRabin returns a hash.Hash64 computing the CRC-64-AVRO (Rabin) fingerprint defined by the Avro specification.

func RatFromBytes added in v1.5.0

func RatFromBytes(b []byte, scale int) *big.Rat

RatFromBytes converts Avro decimal bytes (big-endian two's complement) to a *big.Rat with the given scale. This is the conversion you would otherwise write yourself in a CustomType Decode callback that overrides our built-in decimal handling, since such a callback receives the raw []byte.

We read a negative scale as unscaled * 10^|scale|, matching Java and avro-rs. We bound |scale| and the unscaled byte length; input past either bound returns a zero *big.Rat rather than allocating unbounded.

func SingleObjectFingerprint

func SingleObjectFingerprint(data []byte) (fp [8]byte, rest []byte, err error)

SingleObjectFingerprint extracts the 8-byte CRC-64-AVRO fingerprint and returns the remaining payload.

Types

type CompatibilityError

type CompatibilityError struct {
	// Path is the dotted path to the incompatible element, e.g.
	// "User.address.zip".
	Path string
	// ReaderType is the Avro type in the reader schema.
	ReaderType string
	// WriterType is the Avro type in the writer schema.
	WriterType string
	// Detail describes the specific incompatibility.
	Detail string
}

CompatibilityError describes an incompatibility between a reader and writer schema, as returned by CheckCompatibility and Resolve.

func (*CompatibilityError) Error

func (e *CompatibilityError) Error() string

type CustomType added in v1.3.0

type CustomType struct {
	// LogicalType narrows matching to schema nodes with this logicalType.
	LogicalType string

	// AvroType narrows matching to schema nodes of this Avro type
	// (e.g. "long", "bytes", "record"). Also used by SchemaFor to
	// infer the underlying Avro type.
	AvroType string

	// GoType adds an encode-time filter: when set, your Encode function
	// only fires when the value's concrete type matches GoType. Values
	// of other types pass through to the underlying serializer unchanged.
	// If nil, Encode fires for all values on matched schema nodes
	// (those matching LogicalType/AvroType).
	//
	// [SchemaFor] uses GoType to match struct fields: when a field's Go
	// type equals GoType, we emit AvroType + LogicalType (or Schema)
	// instead of the default type mapping. Because the custom supplies
	// the whole field schema, a logical-type tag on a matched field has
	// no effect and is rejected: set LogicalType (or Schema) here
	// instead. If nil, the custom type does not affect schema
	// generation, but we still wire it into the returned [*Schema] for
	// encode/decode.
	GoType reflect.Type

	// Schema is the full schema to emit in SchemaFor. You only need it
	// for types requiring extra metadata (fixed needs name+size, decimal
	// needs precision+scale, records need fields). If nil, we infer from
	// AvroType + LogicalType.
	//
	// We preserve every fullname the schema declares: a namespaced type
	// keeps its namespace, and a null-namespace type embedded under
	// [WithNamespace] keeps its null namespace (the emitted definition
	// carries a "namespace":"" escape). Note that a null-namespace type
	// used on two or more fields under WithNamespace is an error, because
	// Avro has no way to reference the null namespace from inside another
	// namespace.
	//
	// We work on a private copy, so SchemaFor never mutates your
	// SchemaNode or anything reachable from it. A schema over the
	// schema-tree budgets, or one holding an unnamed pointer cycle, fails
	// the build with an error.
	//
	// A union branch may be written either as a bare name ("null") or as
	// a wrapped object ({"type":"null"}); we treat the two as the same
	// type. A null branch is recognized in both spellings, whatever
	// properties or logicalType the wrapped form carries (Avro defines no
	// null logical type), so a nullable union collapses through a pointer
	// field and receives its null default the same way either way.
	Schema *SchemaNode

	// Encode converts your Go value to an Avro-native value. We call it
	// before serialization with the value as you passed it to
	// [Schema.Encode] (e.g. a custom Money type); return the
	// corresponding Avro-native value (e.g. int64 cents). Return
	// [ErrSkipCustomType] to fall through to the next matching custom
	// type or built-in behavior. Any other non-nil error is fatal.
	//
	// If nil, we use the built-in logical type encoder, which accepts
	// both enriched types ([time.Time], [time.Duration]) and raw
	// values (int64, int32, etc.).
	//
	// We build the schema argument once at Parse and share it across all
	// concurrent invocations. Treat it as read-only; in particular, do
	// not mutate schema.Props or schema.Symbols, whose slices and maps
	// alias the parser's internal state: concurrent writes from multiple
	// goroutines decoding the same [*Schema] will race.
	Encode func(v any, schema *SchemaNode) (any, error)

	// Decode converts a raw Avro-native value to your Go value. We call
	// it after deserialization with the raw Avro-native value (int32 for
	// int, int64 for long, []byte for bytes/fixed, etc.); return the
	// type you want. Return [ErrSkipCustomType] to fall through. Any
	// other non-nil error is fatal.
	//
	// When all matching decoders skip at a node, we re-decode the wire
	// into the target faithfully (identical to a no-custom decode). A
	// wildcard custom (empty LogicalType and AvroType) that matches leaf
	// nodes but skips containers therefore makes decoding into a
	// deeply-nested *typed* target (struct/slice/map) cost O(depth^2).
	// For untrusted deeply-nested data, decode into an interface /
	// map[string]any (single-pass) or register against a specific
	// LogicalType/AvroType.
	//
	// If nil, we bypass the built-in logical type handler and use the
	// base Avro type decoder directly, producing raw Avro-native values
	// (int32, int64, etc.) rather than enriched types ([time.Time],
	// [time.Duration], etc.).
	//
	// The schema argument is shared across concurrent callback invocations;
	// see [CustomType.Encode] for the read-only contract.
	//
	// Under [AliasInput], a []byte v points into the decode input, and a
	// field filled from its schema default points into the parsed
	// [Schema], which every decode of that schema shares. Read it or copy
	// from it, but do not write through it. Returning it is fine.
	Decode func(v any, schema *SchemaNode) (any, error)
	// contains filtered or unexported fields
}

CustomType defines a custom conversion between a Go type and an Avro type. NewCustomType is the simpler form for types backed by a primitive Avro type; this struct is the general form, and the only way to handle records, fixed types, and property-based dispatch.

Pass a CustomType to Parse or SchemaFor as a SchemaOpt.

At parse time we match LogicalType and AvroType against schema nodes. All non-empty criteria must match:

  • LogicalType only: matches any schema node with that logicalType
  • LogicalType + AvroType: matches that logicalType on that Avro type
  • AvroType only: matches all nodes of that Avro type
  • Neither: matches every schema node (use with ErrSkipCustomType for property-based dispatch like Kafka Connect types)

At encode time we check GoType too: your Encode function only fires when the value's type matches GoType. This keeps the codec from intercepting native values (e.g. a raw int64 passes through without conversion for a custom-typed long field).

A matching CustomType replaces our built-in logical type deserializer. Among your registrations, the first match wins.

If the Avro type is complex, your Encode function returns map[string]any, []any, and so on.

Example (Override)
package main

import (
	"fmt"

	"github.com/twmb/avro"
)

func main() {
	// Use CustomType directly to override a built-in logical type handler.
	// Here we suppress the timestamp-millis to time.Time conversion and
	// keep the raw int64 epoch millis.
	schema := avro.MustParse(`{
		"type": "record", "name": "Event",
		"fields": [
			{"name": "ts", "type": {"type": "long", "logicalType": "timestamp-millis"}}
		]
	}`, avro.CustomType{
		LogicalType: "timestamp-millis",
		Decode: func(v any, _ *avro.SchemaNode) (any, error) {
			return v, nil // pass through raw int64
		},
	})

	data, _ := schema.Encode(map[string]any{"ts": int64(1767225600000)})
	var out any
	schema.Decode(data, &out)
	m := out.(map[string]any)
	fmt.Printf("ts type: %T\n", m["ts"])
}
Output:
ts type: int64
Example (PropertyDispatch)
package main

import (
	"fmt"

	"github.com/twmb/avro"
)

func main() {
	// CustomType with no LogicalType/AvroType/GoType matches every schema
	// node. Use ErrSkipCustomType to selectively handle nodes based on
	// schema properties, e.g. Kafka Connect type annotations.
	ct := avro.CustomType{
		Decode: func(v any, node *avro.SchemaNode) (any, error) {
			if node.Props["connect.type"] == "double-it" {
				return v.(int64) * 2, nil
			}
			return nil, avro.ErrSkipCustomType
		},
	}

	// Properties on the type object are available via node.Props in the
	// custom type callback.
	schema := avro.MustParse(`{
		"type": "record", "name": "R",
		"fields": [
			{"name": "x", "type": {"type": "long", "connect.type": "double-it"}},
			{"name": "y", "type": "long"}
		]
	}`, ct)

	data, _ := schema.Encode(map[string]any{"x": int64(5), "y": int64(5)})
	var out any
	schema.Decode(data, &out)
	m := out.(map[string]any)
	fmt.Printf("x=%d y=%d\n", m["x"], m["y"])
}
Output:
x=10 y=5
Example (SchemaFor)
package main

import (
	"fmt"
	"log"
	"reflect"

	"github.com/twmb/avro"
)

func main() {
	// Setting GoType lets SchemaFor infer the Avro schema for struct
	// fields of that type. Without GoType, SchemaFor doesn't know that
	// a Cents field should map to {"type":"long","logicalType":"money"}.
	type Cents int64
	ct := avro.CustomType{
		LogicalType: "money",
		AvroType:    "long",
		GoType:      reflect.TypeFor[Cents](),
		Encode: func(v any, _ *avro.SchemaNode) (any, error) {
			return int64(v.(Cents)), nil
		},
		Decode: func(v any, _ *avro.SchemaNode) (any, error) {
			return Cents(v.(int64)), nil
		},
	}

	type Order struct {
		Price Cents `avro:"price"`
	}
	schema, err := avro.SchemaFor[Order](ct)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(schema.Root().Fields[0].Type.LogicalType)
}
Output:
money

func NewCustomType added in v1.3.0

func NewCustomType[G, A any](
	logicalType string,
	encode func(G, *SchemaNode) (A, error),
	decode func(A, *SchemaNode) (G, error),
) CustomType

NewCustomType returns a type-safe CustomType for the common case of mapping a custom Go type to/from a primitive Avro type.

G is your custom Go type (e.g. Money). A is the Avro-native Go type: int32 for int, int64 for long, float32 for float, float64 for double, string for string, []byte for bytes, bool for boolean. A may also be a named type whose underlying kind is one of these (e.g. type Cents int64); we infer the Avro type from A's kind and convert the decoded value to A.

We infer GoType and AvroType from the type parameters. If A is not a supported Avro-native type, Parse or SchemaFor returns an error.

Note that we infer AvroType from A's Go kind, which may not match the Avro schema's type for logical types backed by smaller types. For example, time-millis uses Avro "int" but time.Duration is int64, which infers "long". Use int32 as A, or use the CustomType struct directly with an explicit AvroType.

For fixed, records, or types needing extra schema metadata, use the CustomType struct directly.

Example
package main

import (
	"fmt"
	"log"

	"github.com/twmb/avro"
)

type ExMoney struct {
	Cents int64
}

func main() {
	// NewCustomType is the easiest way to map a custom Go type to and from a
	// primitive Avro type: G is the custom Go type, A the Avro-native Go type
	// it maps to, and A decides the wire type:
	//   int32 -> int    float32 -> float    bool   -> boolean
	//   int64 -> long   float64 -> double   string -> string   []byte -> bytes
	// The first argument is the logicalType to match; "" matches all schema
	// nodes of the inferred Avro type.
	moneyType := avro.NewCustomType[ExMoney, int64]("money",
		func(m ExMoney, _ *avro.SchemaNode) (int64, error) { return m.Cents, nil },
		func(c int64, _ *avro.SchemaNode) (ExMoney, error) { return ExMoney{Cents: c}, nil },
	)

	schema := avro.MustParse(`{
		"type": "record", "name": "Order",
		"fields": [
			{"name": "price", "type": {"type": "long", "logicalType": "money"}}
		]
	}`, moneyType)

	type Order struct {
		Price ExMoney `avro:"price"`
	}

	data, err := schema.Encode(&Order{Price: ExMoney{Cents: 1999}})
	if err != nil {
		log.Fatal(err)
	}

	var out Order
	if _, err := schema.Decode(data, &out); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%d cents\n", out.Price.Cents)
}
Output:
1999 cents

type Duration

type Duration struct {
	Months       uint32
	Days         uint32
	Milliseconds uint32
}

Duration is the Avro duration logical type: a 12-byte fixed value holding three little-endian uint32s, months and days and milliseconds.

func DurationFromBytes added in v1.3.0

func DurationFromBytes(b []byte) Duration

DurationFromBytes decodes a 12-byte little-endian fixed value into a Duration, returning the zero Duration if b is shorter than 12 bytes. Use it in a CustomType Decode callback, which receives the raw []byte, to read the value before you convert it to your own type.

func (Duration) Bytes added in v1.3.0

func (d Duration) Bytes() [12]byte

Bytes encodes the Duration as a 12-byte little-endian fixed value, matching the Avro duration wire format.

func (Duration) String added in v1.3.0

func (d Duration) String() string

String returns an ISO 8601 duration string. Zero components are omitted. Examples: "P1Y3M15DT1H30M0.500S", "P30D", "PT1H".

type Opt added in v1.3.0

type Opt interface {
	// contains filtered or unexported methods
}

Opt configures encoding and decoding. Each option's doc says which functions it affects; we silently ignore an option that does not apply.

func AliasInput added in v1.9.0

func AliasInput() Opt

AliasInput makes decoded strings and byte slices point into the bytes they were read from, rather than copying them as we do by default. This applies to Schema.Decode and Schema.DecodeSingleObject. Schema.DecodeJSON ignores it, because a JSON string with an escape cannot alias.

You must not modify src after the decode, and you must not modify anything the decode returns. An aliased value *is* the memory it was read from: if you write to src, every string and byte slice decoded from it changes too, even though Go otherwise guarantees strings are immutable. Usually that memory is your src, but a field filled from a schema default aliases the parsed Schema itself, which every decode of that schema shares.

One aliased field keeps the whole buffer it points into alive for as long as you hold it. Do not use this option if you reuse the buffer, or if you keep one small field of a large message around.

We alias string and []byte targets of the string, bytes and fixed kinds, including inside an any, under a uuid logical type, and as map keys. We still copy for [N]byte, encoding.TextUnmarshaler, and any logical type that builds a new Go value (decimal, the timestamps, and the hex-dash uuid form).

This is an Opt rather than a SchemaOpt on purpose: ocf.WithSchemaOpts forwards SchemaOpts into an OCF reader, and a reader must not alias its block buffer. ocf.WithDecodeOpts drops this option for the same reason.

func LinkedinFloats added in v1.3.0

func LinkedinFloats() Opt

LinkedinFloats encodes NaN as JSON null and ±Infinity as ±1e999 in Schema.EncodeJSON, the linkedin/goavro convention, overriding our default of the JSON strings "NaN", "Infinity" and "-Infinity" (the Java convention).

Schema.DecodeJSON accepts both conventions for a float or double decoded directly or as a tagged union branch ({"float":null} decodes to NaN). Note that a NaN inside a *bare* union does not round-trip: it encodes as a bare null, and on decode the union's null branch claims that null (or the union rejects it if it has no null branch) before we try the float branch. Use TaggedUnions if you need NaN to round-trip through a union. ±Infinity is a number token and round-trips in a bare union regardless.

func SkipUnknown added in v1.9.0

func SkipUnknown() Opt

SkipUnknown allows decoding into a struct that maps only some of a record's fields, skipping the fields your struct lacks rather than erroring as we do by default. Nested records follow the same rule. This applies to Schema.Decode, Schema.DecodeJSON and Schema.DecodeSingleObject. It is decode only: encoding from a struct that does not cover the record still errors, since the missing fields would go out as zero values.

Note that an ambiguous field name (two same-depth fields claiming it) still errors. Your type has fields for it, so there is nothing to skip.

func TagLogicalTypes added in v1.3.0

func TagLogicalTypes() Opt

TagLogicalTypes qualifies union branch names with their logical type, "long.timestamp-millis" rather than the spec's "long". This is the linkedin/goavro convention. It applies to Schema.EncodeJSON and Schema.Decode, and only alongside TaggedUnions; without that option it does nothing.

func TaggedUnions added in v1.3.0

func TaggedUnions() Opt

TaggedUnions wraps non-null union values as {"type_name": value}, overriding the default of bare values.

Schema.EncodeJSON emits the tagged form. Schema.Decode and Schema.DecodeJSON wrap union values as map[string]any{branchName: value}, but only when your decode target is *any. A typed target (a concrete struct field, *T, or a non-empty interface) cannot hold the wrapper, so it gets the bare branch value.

Schema.DecodeJSON and Schema.Encode take both tagged and bare union input either way.

The tagged form is what the Avro spec defines for JSON. Java's JsonDecoder and fastavro's JSON decoder reject our bare default on the first non-null union field, so pass TaggedUnions if Java, fastavro, or avro-tools fromjson reads your output. The bare default is for goavro's bare-JSON codecs (NewCodecForStandardJSON and NewCodecForStandardJSONFull) and for plain map[string]any consumers. See AVRO-2899 for the upstream discussion.

Note that a bare union value does not name its branch, so Schema.DecodeJSON cannot tell which branch the writer used when several share a JSON token class: a bare 7 matches int, long, float and double, and a bare "x" matches string, bytes, fixed and enum. We use the *first* such branch in declaration order. That can differ from the writer's branch, and it bypasses a CustomType registered on a later branch of the same class: its Decode never runs, and a typed target is filled by plain coercion from the first branch. Binary Schema.Decode is unaffected, since the wire carries the branch index. If branch identity or a branch-bound CustomType matters to you, encode and decode with TaggedUnions.

type Schema

type Schema struct {
	// contains filtered or unexported fields
}

Schema is a compiled Avro schema. Create one with Parse or MustParse, then use Schema.Encode / Schema.Decode to convert between Go values and Avro binary. A Schema is safe for concurrent use.

func MustParse

func MustParse(schema string, opts ...SchemaOpt) *Schema

MustParse is like Parse but panics on error.

func MustSchemaFor added in v1.1.0

func MustSchemaFor[T any](opts ...SchemaOpt) *Schema

MustSchemaFor is like SchemaFor but panics on error.

func Parse

func Parse(schema string, opts ...SchemaOpt) (*Schema, error)

Parse parses an Avro JSON schema string and returns a compiled *Schema. The input can be a primitive name (e.g. `"string"`), a JSON object (record, enum, array, map, fixed), or a JSON array (union). Named types may self-reference. We fully validate: unknown types, duplicate names, invalid defaults, and so on all return errors.

To parse schemas that reference named types from other schemas, use SchemaCache.

func Resolve

func Resolve(writer, reader *Schema) (*Schema, error)

Resolve returns a schema that decodes data written with the writer schema and produces values matching the reader schema's layout. The writer schema is what the data was encoded with (typically from an OCF file header or a schema registry); the reader schema is what your application expects now.

Decoding with the returned schema handles field addition (defaults), field removal (skip), renaming (aliases), reordering, and type promotion. Encoding with it uses the reader's format.

We run CheckCompatibility first and return any incompatibility as a *CompatibilityError. If the check passes and the two canonical forms are identical, you get reader back as-is. The check runs first because the parsing canonical form strips logicalType, precision and scale, so two schemas with equal canonical forms can still be incompatible (a decimal precision/scale mismatch, for example), and such a pair would otherwise silently rescale the decoded value.

Note that the argument order is (writer, reader), matching the source-then-destination convention and Java's GenericDatumReader. This differs from the Avro spec text and hamba/avro, which put reader first.

Example
package main

import (
	"fmt"
	"log"

	"github.com/twmb/avro"
)

func main() {
	// v1 wrote User with just a name.
	writerSchema := avro.MustParse(`{
		"type": "record", "name": "User",
		"fields": [{"name": "name", "type": "string"}]
	}`)

	// v2 added an email field with a default.
	readerSchema := avro.MustParse(`{
		"type": "record", "name": "User",
		"fields": [
			{"name": "name",  "type": "string"},
			{"name": "email", "type": "string", "default": ""}
		]
	}`)

	resolved, err := avro.Resolve(writerSchema, readerSchema)
	if err != nil {
		log.Fatal(err)
	}

	// Encode a v1 record (name only).
	v1Data, err := writerSchema.Encode(map[string]any{"name": "Alice"})
	if err != nil {
		log.Fatal(err)
	}

	// Decode old data into the new layout; email gets the default.
	type User struct {
		Name  string `avro:"name"`
		Email string `avro:"email"`
	}
	var u User
	if _, err := resolved.Decode(v1Data, &u); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("name=%s email=%q\n", u.Name, u.Email)
}
Output:
name=Alice email=""

func SchemaFor added in v1.1.0

func SchemaFor[T any](opts ...SchemaOpt) (*Schema, error)

SchemaFor infers an Avro schema from the Go type T. T must be a struct.

We take field names from the avro struct tag, falling back to the Go field name. The following tag options are supported:

  • avro:"-" excludes the field
  • avro:",inline" flattens a nested struct's fields into the parent
  • avro:",omitzero" is recorded but does not affect the schema
  • avro:",alias=old_name" adds a field alias (repeatable)
  • avro:",type-alias=old_name" adds an alias to the field's named type (record, enum, fixed; repeatable)
  • avro:",default=value" sets the field's default value (must be last option; scalars only)
  • avro:",timestamp-millis" overrides the logical type (also: timestamp-micros, timestamp-nanos, date, time-millis, time-micros)
  • avro:",decimal(precision,scale)" sets the decimal logical type
  • avro:",uuid" sets the uuid logical type

Type inference:

  • bool -> boolean
  • int8, int16, int32 -> int
  • int, int64, uint32 -> long
  • uint8, uint16 -> int
  • float32 -> float
  • float64 -> double
  • string -> string
  • []byte -> bytes
  • [N]byte -> fixed (size N, name from Go type name or "fixed_N")
  • *T -> ["null", T] union with default null (a pointer chain of any depth, **T or ***T, collapses to the same single nullable union)
  • []T -> array
  • map[string]T -> map
  • struct -> record (recursive)
  • time.Time -> long with timestamp-millis (override with tag)
  • time.Duration -> int with time-millis (override with time-micros; a Duration is a span of time, so date and timestamp-* make no sense for it, and a large Duration overflows the narrower wire type)
  • avro.Duration -> fixed(12) with the duration logical type (recognized by type; it takes no tag and does not accept one)
  • *big.Rat -> requires explicit decimal(p,s) tag
  • [16]byte with uuid tag -> fixed(16) with uuid logical type
  • string (or text marshaler type) with uuid tag -> string with uuid logical type
Example
package main

import (
	"fmt"
	"log"
	"time"

	"github.com/twmb/avro"
)

func main() {
	type Event struct {
		ID     int64     `avro:"id"`
		Name   string    `avro:"name,default=unnamed"`
		Source string    `avro:"source,default=web"`
		Time   time.Time `avro:"ts"`
		Meta   *string   `avro:"meta"` // *T becomes ["null", T] union
	}

	schema := avro.MustSchemaFor[Event](avro.WithNamespace("com.example"))

	// Encode, then decode back.
	meta := "test"
	data, err := schema.Encode(&Event{
		ID:     1,
		Name:   "click",
		Source: "mobile",
		Time:   time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
		Meta:   &meta,
	})
	if err != nil {
		log.Fatal(err)
	}

	var out Event
	if _, err := schema.Decode(data, &out); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("id=%d name=%s source=%s meta=%s\n", out.ID, out.Name, out.Source, *out.Meta)

	// Inspect the inferred schema.
	root := schema.Root()
	for _, f := range root.Fields {
		if f.HasDefault {
			fmt.Printf("field %s: default=%v\n", f.Name, f.Default)
		}
	}
}
Output:
id=1 name=click source=mobile meta=test
field name: default=unnamed
field source: default=web
field meta: default=<nil>

func (*Schema) AppendEncode

func (s *Schema) AppendEncode(dst []byte, v any, opts ...Opt) ([]byte, error)

AppendEncode appends the Avro binary encoding of v to dst. See Schema.Decode for the Go-to-Avro type mapping. On top of the types listed there, we also accept:

Example
package main

import (
	"fmt"
	"log"

	"github.com/twmb/avro"
)

func main() {
	schema := avro.MustParse(`"string"`)

	// AppendEncode reuses a buffer across calls, avoiding allocation.
	var buf []byte
	var err error
	for _, s := range []string{"hello", "world"} {
		buf, err = schema.AppendEncode(buf[:0], s)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Printf("encoded %q: %d bytes\n", s, len(buf))
	}
}
Output:
encoded "hello": 6 bytes
encoded "world": 6 bytes

func (*Schema) AppendEncodeJSON added in v1.3.0

func (s *Schema) AppendEncodeJSON(dst []byte, v any, opts ...Opt) ([]byte, error)

AppendEncodeJSON is like Schema.EncodeJSON but appends to dst.

func (*Schema) AppendSingleObject

func (s *Schema) AppendSingleObject(dst []byte, v any, opts ...Opt) ([]byte, error)

AppendSingleObject appends a Single Object Encoding of v to dst: 2-byte magic, 8-byte CRC-64-AVRO fingerprint, then the Avro binary payload.

Example
package main

import (
	"fmt"
	"log"

	"github.com/twmb/avro"
)

func main() {
	schema := avro.MustParse(`{
		"type": "record",
		"name": "Event",
		"fields": [
			{"name": "id",   "type": "long"},
			{"name": "name", "type": "string"}
		]
	}`)

	type Event struct {
		ID   int64  `avro:"id"`
		Name string `avro:"name"`
	}

	// Encode: 2-byte magic + 8-byte fingerprint + Avro payload.
	data, err := schema.AppendSingleObject(nil, &Event{ID: 1, Name: "click"})
	if err != nil {
		log.Fatal(err)
	}

	// Decode.
	var e Event
	if _, err := schema.DecodeSingleObject(data, &e); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("id=%d name=%s\n", e.ID, e.Name)
}
Output:
id=1 name=click

func (*Schema) Canonical

func (s *Schema) Canonical() []byte

Canonical returns the Parsing Canonical Form of the schema, stripping doc, aliases, defaults, and other non-essential attributes. The result is deterministic and matches Java's reference output byte-for-byte, so Schema.Fingerprint values are interoperable across implementations.

func (*Schema) Decode

func (s *Schema) Decode(src []byte, v any, opts ...Opt) ([]byte, error)

Decode reads Avro binary from src into v and returns the remaining bytes. v must be a non-nil pointer to a type compatible with the schema:

  • null: any (always decodes to nil)
  • boolean: bool, any
  • int, long: int, int8-int64, uint8-uint64, any
  • float: float32, float64, any
  • double: float64, float32, any
  • string: string, []byte, any; also encoding.TextUnmarshaler
  • bytes: []byte, string, any
  • enum: string, int/uint (ordinal), any
  • fixed: [N]byte, []byte, any
  • array: slice, any
  • map: map[string]T, any
  • union: any, *T (for ["null", T] unions), or the matched branch type
  • record: struct (matched by field name or `avro` tag), map[string]any, any

When decoding into *any, primitive types become nil, bool, int32, int64, float32, float64, string, []byte, []any, or map[string]any (for records). Logical types decode to their natural Go equivalents:

  • date, timestamp-millis/micros/nanos: time.Time (UTC)
  • local-timestamp-millis/micros/nanos: time.Time (UTC; wall-clock fields encode/decode as if UTC, matching Java's reference impl)
  • time-millis, time-micros: time.Duration
  • decimal: *math/big.Rat
  • uuid on string: string
  • uuid on fixed(16): [16]byte
  • duration: Duration

To produce JSON from decoded *any data use Schema.EncodeJSON, not a generic JSON encoder: it is schema-aware and converts these types back to their Avro representations (time.Time to epoch integers, []byte to \uXXXX strings).

Decode is liberal in what it accepts: we tolerate non-canonical input rather than rejecting it, such as a non-0/1 boolean byte that Java also reads as false. Encode is canonical, so such input round-trips to the canonical form.

func (*Schema) DecodeJSON added in v1.2.0

func (s *Schema) DecodeJSON(src []byte, v any, opts ...Opt) error

DecodeJSON decodes Avro JSON from src into v. We unwrap union wrappers, convert bytes/fixed strings, and coerce numeric types to match the schema. When v is *any, you get the natural Go value directly.

We accept every input format: tagged and bare unions, the Java and goavro NaN/Infinity conventions, and the linkedin/goavro union branch naming ("long.timestamp-millis" instead of "long"). Pass TaggedUnions to wrap decoded union values when the target is *any.

A bare union value whose JSON token class matches several branches (a bare number against ["long","int"], say) decodes via the first matching branch in declaration order, since the bare form does not name the writer's branch. See TaggedUnions for the consequences.

On a schema returned by Resolve, src is JSON in the writer's shape, as a producer using the writer schema would emit it. We apply full writer-to-reader resolution: promotion, enum-symbol remapping to the reader default, field add and drop, and aliases, matching Java's ResolvingDecoder over a JsonDecoder. Note that this path decodes the writer's JSON and then resolves through a binary decode, so it is slower than a plain DecodeJSON.

Example
package main

import (
	"fmt"
	"log"

	"github.com/twmb/avro"
)

func main() {
	schema := avro.MustParse(`{
		"type": "record",
		"name": "User",
		"fields": [
			{"name": "name",  "type": "string"},
			{"name": "email", "type": ["null", "string"]}
		]
	}`)

	type User struct {
		Name  string  `avro:"name"`
		Email *string `avro:"email"`
	}

	// DecodeJSON accepts both bare and tagged union formats.
	var u1, u2 User
	if err := schema.DecodeJSON([]byte(`{"name":"Alice","email":"a@b.com"}`), &u1); err != nil {
		log.Fatal(err)
	}
	if err := schema.DecodeJSON([]byte(`{"name":"Bob","email":{"string":"b@c.com"}}`), &u2); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%s: %s\n", u1.Name, *u1.Email)
	fmt.Printf("%s: %s\n", u2.Name, *u2.Email)
}
Output:
Alice: a@b.com
Bob: b@c.com

func (*Schema) DecodeSingleObject

func (s *Schema) DecodeSingleObject(data []byte, v any, opts ...Opt) ([]byte, error)

DecodeSingleObject decodes a Single Object Encoding message into v after verifying the magic and fingerprint match this schema.

For a schema returned by Resolve, we also accept the writer's fingerprint, since single-object bytes carry the fingerprint of the schema that produced them.

func (*Schema) Encode

func (s *Schema) Encode(v any, opts ...Opt) ([]byte, error)

Encode encodes v as Avro binary. It is shorthand for AppendEncode(nil, v).

Example (TextMarshaler)
package main

import (
	"fmt"
	"log"
	"net"

	"github.com/twmb/avro"
)

func main() {
	// Types implementing encoding.TextMarshaler are encoded as Avro
	// strings, and encoding.TextUnmarshaler types decode from them.
	schema := avro.MustParse(`{
		"type": "record",
		"name": "Server",
		"fields": [
			{"name": "name", "type": "string"},
			{"name": "ip",   "type": "string"}
		]
	}`)

	type Server struct {
		Name string `avro:"name"`
		IP   net.IP `avro:"ip"`
	}

	data, err := schema.Encode(&Server{
		Name: "web-1",
		IP:   net.IPv4(192, 168, 1, 1),
	})
	if err != nil {
		log.Fatal(err)
	}

	var out Server
	if _, err := schema.Decode(data, &out); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%s: %s\n", out.Name, out.IP)
}
Output:
web-1: 192.168.1.1

func (*Schema) EncodeJSON added in v1.2.0

func (s *Schema) EncodeJSON(v any, opts ...Opt) ([]byte, error)

EncodeJSON encodes v as JSON, using the schema for type-aware encoding. By default we write union values bare and escape non-ASCII bytes/fixed bytes as \uXXXX; see Opt for the options that change the output.

We encode NaN and Infinity as the JSON strings "NaN", "Infinity" and "-Infinity" (the Java convention), or as null and ±1e999 with LinkedinFloats. A generic JSON encoder rejects non-finite floats outright; these forms keep the output valid JSON for any strict parser.

We replace each invalid byte of non-UTF-8 string content with U+FFFD, for string values and map keys at any depth. A JSON string cannot carry arbitrary bytes, so JSON is lossy for such content where Schema.Encode preserves it verbatim. Java behaves the same.

EncodeJSON accepts the same Go types as Schema.Encode. We do not sort map keys, so their output order is non-deterministic.

Note that Java's JsonDecoder, fastavro's JSON decoder and avro-tools fromjson all require the {"type_name": value} envelope and reject bare union values. Pass TaggedUnions for those tools; see its doc and AVRO-2899.

Example
package main

import (
	"fmt"
	"log"

	"github.com/twmb/avro"
)

func main() {
	schema := avro.MustParse(`{
		"type": "record",
		"name": "User",
		"fields": [
			{"name": "name",  "type": "string"},
			{"name": "email", "type": ["null", "string"]}
		]
	}`)

	type User struct {
		Name  string  `avro:"name"`
		Email *string `avro:"email"`
	}
	email := "alice@example.com"
	u := User{Name: "Alice", Email: &email}

	// Default: bare union values.
	bare, err := schema.EncodeJSON(&u)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(bare))

	// TaggedUnions: wrapped as {"type": value}.
	tagged, err := schema.EncodeJSON(&u, avro.TaggedUnions())
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(tagged))
}
Output:
{"name":"Alice","email":"alice@example.com"}
{"name":"Alice","email":{"string":"alice@example.com"}}

func (*Schema) Fingerprint

func (s *Schema) Fingerprint(h hash.Hash) []byte

Fingerprint hashes the schema's canonical form with h and returns the digest. Use NewRabin for the spec's CRC-64-AVRO algorithm, or crypto/sha256 for its 256-bit recommendation.

Note that byte order matters for CRC-64-AVRO. Go writes integer hashes high byte first, as crc32, crc64, adler32 and fnv all do, so NewRabin returns the fingerprint big-endian, while Java, fastavro and the single-object header write the same 64-bit value little-endian. Compare as a uint64, or reverse the bytes. A crypto/sha256 fingerprint has no byte order and already matches Java and fastavro byte for byte.

No call returns the little-endian CRC-64-AVRO form directly. Schema.AppendSingleObject writes it into the message header, SingleObjectFingerprint reads it back, and Schema.DecodeSingleObject verifies it.

func (*Schema) Root added in v1.2.0

func (s *Schema) Root() *SchemaNode

Root returns a SchemaNode tree describing the parsed schema. We preserve all metadata: doc strings, namespaces, custom properties, numeric defaults. See SchemaNode.Props and SchemaField.Default for how values decode.

Reserved Avro attribute names ("type", "name", "namespace", "doc", "aliases", ...) match only by their exact lowercase spelling, as in the Avro reference implementations. A case variant such as "Aliases" is an ordinary custom property: it never binds the attribute, and we report it verbatim in SchemaNode.Props. Parsing applies the same rule, so a schema whose only spelling of a structural key is a case variant ("ITEMS" on an array) fails Parse, because the structural attribute is absent.

A field written in the flat goavro-style format (a bare complex type name with the kind's defining key, such as "symbols" or "items", alongside the field's own keys) appears as it parses: the field's type is the nested definition we lifted out (named after the field for record, error, enum, and fixed), and the keys we moved into the type appear on the type node rather than in SchemaField.Props. SchemaNode.Schema rebuilds the nested form, which parses identically.

Every node converts back to a usable *Schema via SchemaNode.Schema, name-reference nodes included: the tree carries the schema's named-type definitions, so any subtree you extract is self-contained.

Root re-parses the JSON on each call. Cache the result if you access it repeatedly (e.g. in a per-message loop).

func (*Schema) String

func (s *Schema) String() string

String returns the original JSON passed to Parse, preserving all attributes (doc, aliases, defaults, etc.) unlike Schema.Canonical.

type SchemaCache

type SchemaCache struct {
	// contains filtered or unexported fields
}

SchemaCache accumulates named types across multiple SchemaCache.Parse calls, so that a schema can reference types defined in previously parsed schemas. This is how a schema registry's inter-schema references work.

Parse schemas in dependency order: a referenced type must be parsed before the schemas that reference it.

You can parse the same schema string more than once; we return the previously parsed result, so diamond dependencies (A->B->D, A->C->D) need no tracking on your side. Options that change what the string compiles to, custom types or WithLaxNames, skip this deduplication and re-parse, since the string alone no longer identifies the result. We normalize JSON whitespace and key order when deduplicating, but not the Avro canonical form: schemas differing only in formatting dedupe, while differences in non-canonical fields like doc or aliases return a duplicate type error.

Each returned *Schema is fully resolved and independent of the cache. That extends to sub-schemas: a node extracted from Schema.Root converts via SchemaNode.Schema with every cross-parse reference resolved, so you never need the cache again once Parse returns.

Note that WithLaxNames is sticky: if a type is defined with it, pass it to every later Parse that references that type. A schema containing a lax name is not parseable without it, cache or no cache, so re-parsing the referencing schema's Schema.String or Schema.Canonical output also needs WithLaxNames. Schema.Encode and Schema.Decode are unaffected.

The zero value is ready to use. A SchemaCache is safe for concurrent use.

Example
package main

import (
	"fmt"
	"log"

	"github.com/twmb/avro"
)

func main() {
	cache := new(avro.SchemaCache)

	// Parse the Address type first.
	if _, err := cache.Parse(`{
		"type": "record",
		"name": "Address",
		"fields": [
			{"name": "street", "type": "string"},
			{"name": "city",   "type": "string"}
		]
	}`); err != nil {
		log.Fatal(err)
	}

	// User references Address by name.
	schema, err := cache.Parse(`{
		"type": "record",
		"name": "User",
		"fields": [
			{"name": "name",    "type": "string"},
			{"name": "address", "type": "Address"}
		]
	}`)
	if err != nil {
		log.Fatal(err)
	}

	type Address struct {
		Street string `avro:"street"`
		City   string `avro:"city"`
	}
	type User struct {
		Name    string  `avro:"name"`
		Address Address `avro:"address"`
	}

	data, err := schema.Encode(&User{
		Name:    "Alice",
		Address: Address{Street: "123 Main St", City: "Springfield"},
	})
	if err != nil {
		log.Fatal(err)
	}

	var u User
	if _, err := schema.Decode(data, &u); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%s lives at %s, %s\n", u.Name, u.Address.Street, u.Address.City)
}
Output:
Alice lives at 123 Main St, Springfield

func (*SchemaCache) Parse

func (c *SchemaCache) Parse(schema string, opts ...SchemaOpt) (*Schema, error)

Parse parses a schema string, registering any named types (records, enums, fixed) in the cache. Named types from previous Parse calls are available for reference resolution. On failure we do not modify the cache.

type SchemaField added in v1.2.0

type SchemaField struct {
	Name string     // field name
	Type SchemaNode // field schema

	// Default is the field's default value, present when HasDefault is
	// true. The Go type matches the schema:
	//
	//   - int schemas give int32, long schemas give int64. We reject
	//     out-of-range defaults at parse.
	//   - float schemas give float32, double float64. Overflows narrow to
	//     ±Inf; NaN, ±Inf, and a float-syntax "-0.0" round-trip. An
	//     integer-syntax "-0" is the sign-less integer 0 and reads as +0.0
	//     (matching Java and fastavro), though the wire encoder writes -0.0
	//     for it.
	//   - string and enum schemas give string.
	//   - bytes and fixed give []byte, already decoded from the JSON spec's
	//     codepoint-per-byte form.
	//   - record, array, and map give map[string]any or []any, each leaf
	//     following these same rules.
	//
	// Union defaults pick the first branch that accepts the value, and the Go
	// type tells you which: ["float","int"] with default 42 gives float32(42).
	//
	// Unlike Props, a numeric Default is never json.Number: we reject defaults
	// that do not fit the declared type.
	Default any

	HasDefault bool     // true if a default value is defined in the schema
	Aliases    []string // field aliases for schema evolution
	Order      string   // sort order: "ascending" (default), "descending", or "ignore"
	Doc        string   // documentation string

	// Props holds custom (non-reserved) field properties; numbers decode as
	// in [SchemaNode.Props]. A field-level "logicalType", "precision", and
	// "scale" appear here as written even when we lift them onto the
	// field's type for encoding and decoding. An unused precision or scale
	// is an ordinary property whatever its JSON shape; we only validate the
	// pair when a decimal logicalType on a bytes or fixed field uses it.
	Props map[string]any
	// contains filtered or unexported fields
}

SchemaField represents a field in an Avro record schema.

type SchemaNode added in v1.2.0

type SchemaNode struct {
	Type        string // Avro type or named type reference
	LogicalType string // e.g. date, timestamp-millis, decimal, uuid; empty if none (or if the value is not a string; see Props)

	Name string // name for record, enum, fixed

	// Namespace is the named type's resolved namespace. [Schema.Root] fills it
	// for every named type, a child that inherits its enclosing namespace
	// shows that namespace here, and "" always means the null namespace,
	// never "inherit". [SchemaNode.Schema] emits a "namespace":"" escape when
	// a null-namespace type sits inside a namespaced scope, so the distinction
	// survives the round trip. A dotted Name takes precedence over this field.
	Namespace string

	Aliases []string // alternate names for named types (record, enum, fixed)
	Doc     string   // documentation string

	Fields   []SchemaField // record fields
	Items    *SchemaNode   // array element schema
	Values   *SchemaNode   // map value schema
	Branches []SchemaNode  // union member schemas
	Symbols  []string      // enum symbols
	Size     int           // fixed byte size

	EnumDefault    string // default symbol for enum schema evolution
	HasEnumDefault bool   // true if an enum default is defined

	// Precision and Scale are the decimal logical type's parameters. We set
	// and validate them only when LogicalType is "decimal" on a bytes or
	// fixed type. Anywhere else (no logical type, an unknown or non-decimal
	// one, or a decimal on a type that does not support it) the attributes
	// are plain metadata and appear in Props, as at the field level.
	Precision int // decimal precision
	Scale     int // decimal scale

	// Props holds custom (non-reserved) schema attributes: anything in the
	// schema JSON that is not a standard Avro field (e.g. "com.example.tag").
	// A reserved structural key on a kind that does not use it ("items" on
	// an "int") also appears here when its body does not parse as a schema
	// (a stray "items":3), and the matching structural field stays zero. A
	// schema-shaped stray body instead appears as written on Items, Values,
	// or Fields. A non-string logicalType likewise appears here verbatim,
	// since only a string can name a logical type.
	//
	// Values use the natural Go types from JSON: string, bool, nil, []any,
	// map[string]any, int64 for whole numbers, float64 for fractional. A
	// number stays json.Number when neither fits: a whole number too large
	// for int64, or a fractional literal over 1024 bytes, whose digits are
	// kept verbatim rather than rounded. Whole-valued exponents collapse to
	// int64 (1e3 reads as int64(1000)); exponents overflowing float64 give
	// ±Inf. math.NaN() re-reads as the string "NaN" after Schema()/Root(),
	// because JSON has no NaN literal; ±Inf round-trips as float64(±Inf).
	//
	// When you build a node by hand, a map key with a MarshalText method
	// renders as that text whatever the key's kind, a float-kind key is an
	// error, and invalid UTF-8 in a string or key becomes U+FFFD. None of
	// this depends on the Go version. encoding/json changed all three
	// between Go 1.26 and Go 1.27, so we name keys and replace bytes
	// ourselves before it runs.
	Props map[string]any
	// contains filtered or unexported fields
}

SchemaNode is a read-write representation of an Avro schema. You get one from a parsed schema via Schema.Root, or you build one directly and convert it with SchemaNode.Schema.

The Type field determines which other fields are relevant:

  • Primitives (null, boolean, int, long, float, double, string, bytes): LogicalType, Precision, Scale, Props optional; other fields ignored.
  • record/error: Name, Fields required; Namespace, Doc, Props optional.
  • enum: Name, Symbols required; Namespace, Doc, Props optional.
  • array: Items required.
  • map: Values required.
  • fixed: Name, Size required; LogicalType, Precision, Scale, Namespace, Props optional.
  • union: Branches lists the member schemas.

To reference a named type (record, enum, fixed) defined elsewhere in the schema, set Type to its full name (e.g. com.example.Address) and nothing else. In a Schema.Root tree, references also resolve outward. Converting *any* node with SchemaNode.Schema resolves names against the schema the tree came from, so a field type, union branch, or deeper node converts even when the definition lives outside the extracted node. A hand-built tree has no enclosing schema, so there you must define every referenced name within the tree you convert, or Schema returns an error.

func (*SchemaNode) ExpandReferences added in v1.9.0

func (n *SchemaNode) ExpandReferences() *SchemaNode

ExpandReferences returns a copy of n's tree with every name reference replaced by the definition it names, so each occurrence of a repeated named type carries the full body rather than only the first. n is not modified.

A reference resolves the way SchemaNode.Schema resolves it, so a subtree extracted from a Schema.Root tree expands even when the definition lives outside it.

Some references stay as they are. A name that closes a cycle never expands, since expanding a recursive definition does not terminate. A reference that carries attributes of its own ({"type":"Inner","doc":"x"}) stays, because a definition cannot hold a second doc. And if the fully expanded tree would exceed an internal ceiling, nothing expands at all, since a chain of definitions each naming the previous twice doubles per level; we return an unexpanded copy rather than a partial one.

SchemaNode.Schema collapses repeats back to references on emit, so n.ExpandReferences().Schema() and n.Schema() produce the same schema. It spells a collapsed repeat by fullname, so a reference you wrote as an in-scope short name comes back qualified.

func (*SchemaNode) Schema added in v1.2.0

func (n *SchemaNode) Schema(opts ...SchemaOpt) (*Schema, error)

Schema parses the SchemaNode into a *Schema you can encode and decode with. We return an error if the node is invalid.

Named types appearing multiple times are deduplicated by fullname: the first occurrence emits the definition, later ones emit the fullname as a reference. Two types sharing a short name across namespaces are distinct and both emit definitions.

A node extracted from a Schema.Root tree may reference definitions living elsewhere in the enclosing schema: an earlier field, a prior SchemaCache parse, or the enclosing type itself for a recursive schema. Those resolve automatically. We emit the definition at the reference's first occurrence, so the result needs neither the enclosing schema nor any cache. A name the tree defines itself wins over the enclosing schema's definition. Custom properties on a wrapped reference move onto the emitted definition, while reserved usage-site attributes (doc, namespace) do not survive. Hand-built nodes have no enclosing schema, so there a reference the tree does not define is an error.

opts pass through to the internal Parse. If you parsed the original schema with [SchemaOpt]s that change what Parse accepts or wires (WithLaxNames, CustomType registrations), pass the same opts here. Otherwise the rebuilt schema fails to parse or silently lacks the custom wiring.

type SchemaOpt added in v1.1.0

type SchemaOpt interface {
	// contains filtered or unexported methods
}

SchemaOpt configures schema construction via Parse, SchemaCache.Parse, or SchemaFor. We silently ignore an option that does not apply.

func WithCustomType added in v1.3.0

func WithCustomType(ct CustomType) SchemaOpt

WithCustomType registers a custom type conversion for Parse, SchemaCache.Parse, or SchemaFor. CustomType and NewCustomType satisfy SchemaOpt directly, so this wrapper is optional.

func WithLaxNames

func WithLaxNames(fn func(string) error) SchemaOpt

WithLaxNames relaxes name validation in Parse and SchemaCache.Parse, overriding our default of the Avro strict name regex [A-Za-z_][A-Za-z0-9_]*. A nil fn requires only non-empty names; otherwise we split dot-separated fullnames and call fn for each name component, and you return an error for the names you reject. SchemaFor ignores this option.

func WithName added in v1.1.0

func WithName(name string) SchemaOpt

WithName overrides the Avro record name in SchemaFor, which otherwise uses the Go struct name. Ignored by Parse.

func WithNamespace added in v1.1.0

func WithNamespace(ns string) SchemaOpt

WithNamespace sets the Avro namespace for the top-level record in SchemaFor. Ignored by Parse.

type SemanticError

type SemanticError struct {
	// GoType is the Go type involved, if applicable.
	GoType reflect.Type
	// AvroType is the Avro schema type (e.g. "int", "record", "boolean").
	AvroType string
	// Field is the dotted path to the record field (e.g. "address.zip"),
	// if the error occurred within a record.
	Field string
	// Err is the underlying error.
	Err error
}

SemanticError indicates a Go type is incompatible with an Avro schema type during encoding or decoding.

func (*SemanticError) Error

func (e *SemanticError) Error() string

func (*SemanticError) Unwrap

func (e *SemanticError) Unwrap() error

type ShortBufferError

type ShortBufferError struct {
	// Type is what was being read (e.g. "boolean", "string", "uint32").
	Type string
	// Need is the number of bytes required (0 if unknown).
	Need int
	// Have is the number of bytes available.
	Have int
}

ShortBufferError indicates the input buffer is too short for the value being decoded.

func (*ShortBufferError) Error

func (e *ShortBufferError) Error() string

Directories

Path Synopsis
Package atype names the Avro schema types, logical types, and field sort orders.
Package atype names the Avro schema types, logical types, and field sort orders.
internal
optmark
Package optmark marks avro decode options by a property the code hosting a decode has to act on.
Package optmark marks avro decode options by a property the code hosting a decode has to act on.
Package ocf implements Avro [Object Container Files] (OCF).
Package ocf implements Avro [Object Container Files] (OCF).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL