SnakeBridge implements a tagged JSON type system for lossless Elixir-Python round-trips. Values that map directly to JSON pass through unchanged. Types without direct JSON equivalents use tagged representations with __type__ markers.

Schema Version and Format

The current schema version is 1. Tagged values follow this structure:

{"__type__": "<type_tag>", "__schema__": 1, "<payload_key>": "<value>"}

Both Elixir and Python encoders produce identical wire formats.

Primitive Types

Primitives pass through JSON encoding without tagging:

PythonElixirJSON
Nonenilnull
True / Falsetrue / falsetrue / false
intinteger()number
floatfloat()number
strString.t()string

Tagged Types

Bytes

{"__type__": "bytes", "__schema__": 1, "data": "aGVsbG8="}

Binary data uses base64 encoding. Elixir decodes to raw binary.

Tuple

{"__type__": "tuple", "__schema__": 1, "elements": [1, 2, 3]}

Elixir tuples and Python tuples share this wire format.

Complex Numbers

{"__type__": "complex", "__schema__": 1, "real": 1.0, "imag": 2.0}

Elixir decodes these as maps: %{real: 1.0, imag: 2.0}.

DateTime Types

{"__type__": "datetime", "__schema__": 1, "value": "2026-01-11T12:00:00Z"}
{"__type__": "date", "__schema__": 1, "value": "2026-01-11"}
{"__type__": "time", "__schema__": 1, "value": "12:00:00"}

Elixir decodes to DateTime, Date, and Time structs.

Set and Frozenset

{"__type__": "set", "__schema__": 1, "elements": [1, 2, 3]}
{"__type__": "frozenset", "__schema__": 1, "elements": [1, 2, 3]}

Both decode to Elixir MapSet.

Non-String Key Dict

{"__type__": "dict", "__schema__": 1, "pairs": [[1, "one"], [2, "two"]]}

Preserves integer, tuple, or other non-string keys across the wire.

Atom

{"__type__": "atom", "__schema__": 1, "value": "ok"}

Security: Only allowlisted atoms are decoded (default: ["ok", "error"]).

config :snakebridge, atom_allowlist: ["ok", "error", "status"]

Special Floats

{"__type__": "special_float", "__schema__": 1, "value": "infinity"}
{"__type__": "special_float", "__schema__": 1, "value": "neg_infinity"}
{"__type__": "special_float", "__schema__": 1, "value": "nan"}

Elixir decodes as atoms: :infinity, :neg_infinity, :nan.

Refs

{"__type__": "ref", "__schema__": 1, "id": "abc123", "session_id": "xyz", "type_name": "Pattern"}

Non-serializable Python objects become refs. See Refs and Sessions guide.

Stream Refs

{"__type__": "stream_ref", "__schema__": 1, "id": "def456", "session_id": "xyz", "stream_type": "generator"}

Generators and iterators implement the Enumerable protocol.

Callbacks

{"__type__": "callback", "__schema__": 1, "ref_id": "cb789", "pid": "<0.123.0>", "arity": 2}

Elixir functions passed to Python for callbacks.

Python to Elixir Type Mapping

Generated wrappers use these typespec mappings:

Python TypeElixir Typespec
intinteger()
floatfloat()
strString.t()
boolboolean()
bytesbinary()
Nonenil
list[T]list(T)
dict[K, V]%{optional(K) => V}
tuple[T1, T2]{T1, T2}
set[T]MapSet.t(T)
Optional[T]T | nil
Union[T1, T2]T1 | T2
ClassNameClassName.t()
Anyterm()

Encoding (Elixir to Python)

The encoder in SnakeBridge.Types.Encoder handles conversion:

# Primitives pass through
encode(42)        # => 42
encode("hello")   # => "hello"

# Tuples are tagged
encode({:ok, 1})
# => %{"__type__" => "tuple", "__schema__" => 1,
#      "elements" => [%{"__type__" => "atom", ...}, 1]}

# MapSets are tagged
encode(MapSet.new([1, 2, 3]))
# => %{"__type__" => "set", "__schema__" => 1, "elements" => [1, 2, 3]}

# Maps with atom keys convert to string keys
encode(%{a: 1, b: 2})
# => %{"a" => 1, "b" => 2}

# Maps with non-string keys use tagged dict
encode(%{1 => "one", 2 => "two"})
# => %{"__type__" => "dict", "__schema__" => 1, "pairs" => [[1, "one"], [2, "two"]]}

Decoding (Python to Elixir)

The decoder in SnakeBridge.Types.Decoder reconstructs Elixir types:

# Primitives pass through
decode(42)        # => 42
decode("hello")   # => "hello"

# Tagged tuples become tuples
decode(%{"__type__" => "tuple", "elements" => [1, 2, 3]})
# => {1, 2, 3}

# Tagged sets become MapSets
decode(%{"__type__" => "set", "elements" => [1, 2, 3]})
# => #MapSet<[1, 2, 3]>

# Refs become SnakeBridge.Ref structs
decode(%{"__type__" => "ref", "id" => "abc", "session_id" => "xyz"})
# => %SnakeBridge.Ref{id: "abc", session_id: "xyz", ...}

Using SnakeBridge.bytes/1

By default, UTF-8 valid Elixir binaries encode as Python strings. Use SnakeBridge.bytes/1 when Python expects bytes:

# Without bytes wrapper - TypeError: Strings must be encoded before hashing
{:ok, _} = SnakeBridge.call("hashlib", "md5", ["abc"])

# With bytes wrapper - works correctly
{:ok, hash} = SnakeBridge.call("hashlib", "md5", [SnakeBridge.bytes("abc")])

# Binary data for protocols
{:ok, _} = SnakeBridge.call("base64", "b64encode", [SnakeBridge.bytes("hello")])

Non-UTF-8 binaries automatically encode as bytes:

binary = <<0, 1, 2, 255>>
{:ok, _} = SnakeBridge.call("module", "process_bytes", [binary])

Graceful Serialization

Graceful serialization preserves container structure when returning Python data. Only non-serializable leaf values become refs, not entire containers.

Container Preservation

# Python returns:
{"name": "validator", "required": True, "pattern": re.compile(r"...")}
# Elixir receives:
%{
  "name" => "validator",           # direct access
  "required" => true,              # direct access
  "pattern" => %SnakeBridge.Ref{}  # usable ref
}

Leaf-Level Ref Creation

Only the non-serializable value becomes a ref:

[1, 2, re.compile(r"^\d+$"), 4]
[1, 2, %SnakeBridge.Ref{type_name: "Pattern"}, 4]

# Access serializable elements directly
Enum.at(result, 0)  # => 1

# Use ref for Python operations
pattern = Enum.at(result, 2)
{:ok, match} = SnakeBridge.method(pattern, "match", ["123"])

Mixed Structures

Deeply nested structures preserve all levels:

{"level1": {"level2": {"level3": [1, 2, re.compile(r"..."), 4]}}}
# All levels preserved, only the pattern becomes a ref
result["level1"]["level2"]["level3"]
# => [1, 2, %SnakeBridge.Ref{}, 4]

Working with Refs in Containers

{:ok, config} = SnakeBridge.call("validators", "get_config", [])

# Access serializable fields directly
config["name"]      # => "phone_validator"
config["required"]  # => true

# Use ref for Python operations
pattern = config["pattern"]
{:ok, match} = SnakeBridge.method(pattern, "match", ["555-1234"])
{:ok, pattern_str} = SnakeBridge.attr(pattern, "pattern")

Generators in Containers

Generators become StreamRef while the container remains accessible:

{"status": "ok", "stream": (x for x in range(10))}
%{"status" => "ok", "stream" => %SnakeBridge.StreamRef{}}

result["status"]              # => "ok"
result["stream"] |> Enum.take(5)  # => [0, 1, 2, 3, 4]

See Also