JSON Writing

This guide to writing JSON in the JSON.jl package aims to:

  • Provide a comprehensive overview of the JSON serialization process.
  • Explain the various options and configurations available for writing JSON data.
  • Offer practical examples to illustrate the usage of different functions and options.

Core JSON Serialization - JSON.json

The main entrypoint for serializing Julia values to JSON in JSON.jl is the JSON.json function. This function offers flexible output options:

# Serialize to a String
JSON.json(x) -> String

# Serialize to an IO object
JSON.json(io::IO, x) -> IO

# Serialize to a file
JSON.json(file_name::String, x) -> String

The JSON.json function accepts a wide range of Julia types and transforms them into their JSON representation by knowing how to serialize a core set of types:

Julia typeJSON representation
Nothingnull
Booltrue or false
NumberNumeric value (integer or floating point)
AbstractStringString with escaped characters
AbstractDict/NamedTupleObject ({})
AbstractVector/Tuple/SetArray ([])
Custom structsObject ({}) with fields as keys
JSONTextRaw JSON (inserted as-is)

For values that don't fall into one of the above categories, JSON.lower will be called allowing a "domain transformation" from Julia value to an appropriate representation of the categories above.

Customizing JSON Output

JSON.json supports numerous keyword arguments to control how data is serialized:

Pretty Printing

By default, JSON.json produces compact JSON without extra whitespace. For human-readable output:

# Boolean flag for default pretty printing (2-space indent)
JSON.json(x; pretty=true)

# Or specify custom indentation level
JSON.json(x; pretty=4)  # 4-space indentation

Example of pretty printing:

data = Dict("name" => "Alice", "scores" => [95, 87, 92])

# Compact output
JSON.json(data)
# {"name":"Alice","scores":[95,87,92]}

# Pretty printed
JSON.json(data; pretty=true)
# {
#   "name": "Alice",
#   "scores": [
#     95,
#     87,
#     92
#   ]
# }

When pretty printing, you can also control which arrays get printed inline versus multiline using the inline_limit option:

JSON.json(data; pretty=true, inline_limit=10)
# {
#   "name": "Alice",
#   "scores": [95, 87, 92]
# }

Null and Empty Value Handling

JSON.json provides options to control how nothing, missing, and empty collections are handled:

struct Person
    name::String
    email::Union{String, Nothing}
    phone::Union{String, Nothing}
    tags::Vector{String}
end

person = Person("Alice", "alice@example.com", nothing, String[])

# Default behavior writes all values, including null
JSON.json(person)
# {"name":"Alice","email":"alice@example.com","phone":null,"tags":[]}

# Exclude null values
JSON.json(person; omit_null=true)
# {"name":"Alice","email":"alice@example.com","tags":[]}

# Omit empty collections as well
JSON.json(person; omit_null=true, omit_empty=true)
# {"name":"Alice","email":"alice@example.com"}

Note that we can also control whether null or empty values are omitted at the type level, either by overloading omit_null/omit_empty functions:

JSON.omit_null(::Type{Person}) = true

Or by using a convenient macro annotation when defining the struct:

@omit_null struct Person
    name::String
    email::Union{String, Nothing}
    phone::Union{String, Nothing}
    tags::Vector{String}
end

Special Numeric Values

By default, JSON.json throws an error when trying to serialize NaN, Inf, or -Inf as they are not valid JSON. However, you can enable them with the allownan option:

numbers = [1.0, NaN, Inf, -Inf]

# Default behavior throws an error
try
    JSON.json(numbers)
catch e
    println(e)
end
# ArgumentError("NaN not allowed to be written in JSON spec; pass `allownan=true` to allow anyway")

# With allownan=true
JSON.json(numbers; allownan=true)
# [1.0,NaN,Infinity,-Infinity]

# Custom representations
JSON.json(numbers; allownan=true, nan="null", inf="1e999", ninf="-1e999")
# [1.0,null,1e999,-1e999]

Float Formatting

Control how floating-point numbers are formatted in the JSON output:

pi_value = [Float64(π)]

# Default format (shortest representation)
JSON.json(pi_value)
# [3.141592653589793]

# Fixed decimal notation
JSON.json(pi_value; float_style=:fixed, float_precision=2)
# [3.14]

# Scientific notation
JSON.json(pi_value; float_style=:exp, float_precision=3)
# [3.142e+00]

JSON Lines Format

The JSON Lines format is useful for streaming records where each line is a JSON value:

records = [
    Dict("id" => 1, "name" => "Alice"),
    Dict("id" => 2, "name" => "Bob"),
    Dict("id" => 3, "name" => "Charlie")
]

# Standard JSON array
JSON.json(records)
# [{"id":1,"name":"Alice"},{"id":2,"name":"Bob"},{"id":3,"name":"Charlie"}]

# JSON Lines format; each object on its own line, no begining or ending square brackets
JSON.json(records; jsonlines=true)
# {"id":1,"name":"Alice"}
# {"id":2,"name":"Bob"}
# {"id":3,"name":"Charlie"}

Customizing Types

Using JSON.JSONText

The JSONText type allows you to insert raw, pre-formatted JSON directly:

data = Dict(
    "name" => "Alice",
    "config" => JSON.JSONText("{\"theme\":\"dark\",\"fontSize\":16}")
)

JSON.json(data)
# {"name":"Alice","config":{"theme":"dark","fontSize":16}}

Custom Type Serialization with lower

For full control over how a type is serialized, you can define a JSON.lower method:

struct Coordinate
    lat::Float64
    lon::Float64
end

# Serialize as an array instead of an object
JSON.lower(c::Coordinate) = [c.lat, c.lon]

point = Coordinate(40.7128, -74.0060)
JSON.json(point)
# [40.7128,-74.006]

# For serializing custom formats
struct UUID
    value::String
end

JSON.lower(u::UUID) = u.value

JSON.json(UUID("123e4567-e89b-12d3-a456-426614174000"))
# "123e4567-e89b-12d3-a456-426614174000"

Custom Serialization for Non-Owned Types

To customize serialization for types you don't own (those from other packages), you can use a custom style:

using Dates

# Create a custom style that inherits from JSONStyle
struct DateTimeStyle <: JSON.JSONStyle end

# Define how to serialize Date and DateTime in this style
JSON.lower(::DateTimeStyle, d::Date) = string(d)
JSON.lower(::DateTimeStyle, dt::DateTime) = Dates.format(dt, "yyyy-mm-dd HH:MM:SS")

# Use the custom style
JSON.json(Date(2023, 1, 1); style=DateTimeStyle())
# "2023-01-01"

JSON.json(DateTime(2023, 1, 1, 12, 30, 45); style=DateTimeStyle())
# "2023-01-01 12:30:45"

Customizing Struct Serialization

Field Names and Tags

The JSON.jl package integrates with StructUtils.jl for fine-grained control over struct serialization. StructUtils.jl provides convenient "struct" macros:

  • @noarg: generates a "no-argument" constructor (T())
  • @kwarg: generates an all-keyword-argument constructor, similar to Base.@kwdef; (T(; kw1=v1, kw2=v2, ...))
  • @tags/@defaults: convenience macros to enable specifying field defaults and field tags
  • @nonstruct: marks a struct as non-struct-like, treating it as a primitive type for serialization

Each struct macro also supports the setting of field default values (using the same syntax as Base.@kwdef), as well as specifying "field tags" using the &(tag=val,) syntax.

using JSON, StructUtils

# Using the @tags macro to customize field serialization
@tags struct User
    user_id::Int &(json=(name="id",),)
    first_name::String &(json=(name="firstName",),)
    last_name::String &(json=(name="lastName",),)
    created_at::DateTime &(json=(dateformat="yyyy-mm-dd",),)
    internal_note::String &(json=(ignore=true,),)
end

user = User(123, "Jane", "Doe", DateTime(2023, 5, 8), "Private note")

JSON.json(user)
# {"id":123,"firstName":"Jane","lastName":"Doe","created_at":"2023-05-08"}

The various field tags allow:

  • Renaming fields with name
  • Custom date formatting with dateformat
  • Excluding fields from JSON output with ignore=true

Default Values with @defaults

Combine with the @defaults macro to provide default values:

@defaults struct Configuration
    port::Int = 8080
    host::String = "localhost"
    debug::Bool = false
    timeout::Int = 30
end

config = Configuration(9000)
JSON.json(config)
# {"port":9000,"host":"localhost","debug":false,"timeout":30}

Handling Circular References

JSON.json automatically detects circular references to prevent infinite recursion:

mutable struct Node
    value::Int
    next::Union{Nothing, Node}
end

# Create a circular reference
node = Node(1, nothing)
node.next = node

# Without circular detection, this would cause a stack overflow
JSON.json(node; omit_null=false)
# {"value":1,"next":null}

Custom Dictionary Key Serialization

For dictionaries with non-string keys, JSON.json has a few default lowerkey definitions to convert keys to strings:

# Integer keys
JSON.json(Dict(1 => "one", 2 => "two"))
# {"1":"one","2":"two"}

# Symbol keys
JSON.json(Dict(:name => "Alice", :age => 30))
# {"name":"Alice","age":30}

# Custom key serialization
struct CustomKey
    id::Int
end

dict = Dict(CustomKey(1) => "value1", CustomKey(2) => "value2")
try
    JSON.json(dict)
catch e
    println(e)
end
# ArgumentError("No key representation for CustomKey. Define StructUtils.lowerkey(::CustomKey)")

# Define how the key should be converted to a string
StructUtils.lowerkey(::JSON.JSONStyle, k::CustomKey) = "key-$(k.id)"

JSON.json(dict)
# {"key-1":"value1","key-2":"value2"}

Advanced Example: The FrankenStruct

Let's explore a comprehensive example that showcases many of JSON.jl's advanced serialization features:

using Dates, JSON, StructUtils

abstract type AbstractMonster end

struct Dracula <: AbstractMonster
    num_victims::Int
end

struct Werewolf <: AbstractMonster
    witching_hour::DateTime
end

struct Percent <: Number
    value::Float64
end

JSON.lower(x::Percent) = x.value
StructUtils.lowerkey(x::Percent) = string(x.value)

@noarg mutable struct FrankenStruct
    id::Int
    name::String # no default to show serialization of an undefined field
    address::Union{Nothing, String} = nothing
    rate::Union{Missing, Float64} = missing
    type::Symbol = :a &(json=(name="franken_type",),)
    notsure::Any = JSON.Object("key" => "value")
    monster::AbstractMonster = Dracula(10) &(json=(lower=x -> x isa Dracula ? 
        (monster_type="vampire", num_victims=x.num_victims) : 
        (monster_type="werewolf", witching_hour=x.witching_hour),),)
    percent::Percent = Percent(0.5)
    birthdate::Date = Date(2025, 1, 1) &(json=(dateformat="yyyy/mm/dd",),)
    percentages::Dict{Percent, Int} = Dict{Percent, Int}(Percent(0.0) => 0, Percent(1.0) => 1)
    json_properties::JSONText = JSONText("{\"key\": \"value\"}")
    matrix::Matrix{Float64} = [1.0 2.0; 3.0 4.0]
    extra_field::Any = nothing &(json=(ignore=true,),)
end

franken = FrankenStruct()
franken.id = 1

json = JSON.json(franken)
# "{\"id\":1,\"name\":null,\"address\":null,\"rate\":null,\"franken_type\":\"a\",\"notsure\":{\"key\":\"value\"},\"monster\":{\"monster_type\":\"vampire\",\"num_victims\":10},\"percent\":0.5,\"birthdate\":\"2025/01/01\",\"percentages\":{\"1.0\":1,\"0.0\":0},\"json_properties\":{\"key\": \"value\"},\"matrix\":[[1.0,3.0],[2.0,4.0]]}"

Let's analyze each part of this complex example to understand how JSON.jl's serialization features work:

Custom Type Serialization Strategy

  1. The AbstractMonster Type Hierarchy:

    • We define an abstract type AbstractMonster with two concrete subtypes: Dracula and Werewolf
    • Each type contains type-specific data (number of victims vs. witching hour)
  2. Custom Numeric Type:

    • Percent is a custom numeric type that wraps a Float64
    • We provide two serialization methods:
      • JSON.lower(x::Percent) = x.value: This tells JSON how to serialize a Percent value (convert to the underlying Float64)
      • StructUtils.lowerkey(x::Percent) = string(x.value): This handles when a Percent is used as a dictionary key
  3. The FrankenStruct:

    • Created with @noarg making it a mutable struct that can be default constructed like FrankenStruct()

Field-Level Serialization Control

Let's examine each field of FrankenStruct in detail:

  1. Basic Fields:

    • id::Int: Standard integer field (initialized explicitly to 1)
    • name::String: Intentionally left uninitialized to demonstrate #undef serialization
  2. Null Handling and Unions:

    • address::Union{Nothing, String} = nothing: Demonstrates how Nothing values are serialized
    • rate::Union{Missing, Float64} = missing: Shows how Missing values are serialized (both become null in JSON)
  3. Field Renaming with Tags:

    • type::Symbol = :a &(json=(name="franken_type",),):
      • The name tag changes the output JSON key from "type" to "franken_type"
      • The value :a is automatically serialized as the string "a" through a default lower method for symbols
  4. Any Type:

    • notsure::Any = JSON.Object("key" => "value"): Shows how JSON handles arbitrary types
  5. Field-Specific Custom Serialization:

    • monster::AbstractMonster = Dracula(10) &(json=(lower=x -> x isa Dracula ? 
          (monster_type="vampire", num_victims=x.num_victims) : 
          (monster_type="werewolf", witching_hour=x.witching_hour),),)
      • This demonstrates field-specific custom serialization using the lower field tag
      • The lambda function checks the concrete type and produces a different JSON structure based on the type
      • For Dracula, it adds a "monster_type": "vampire" field
      • For Werewolf, it would add a "monster_type": "werewolf" field
      • Unlike a global JSON.lower method, this approach only applies when this specific field is serialized
  6. Custom Numeric Type:

    • percent::Percent = Percent(0.5): Uses the global JSON.lower we defined to serialize as 0.5
  7. Custom Date Formatting:

    • birthdate::Date = Date(2025, 1, 1) &(json=(dateformat="yyyy/mm/dd",),):
      • The dateformat field tag controls how the date is formatted
      • Instead of ISO format ("2025-01-01"), it's serialized as "2025/01/01"
  8. Dictionary with Custom Keys:

    • percentages::Dict{Percent, Int} = Dict{Percent, Int}(Percent(0.0) => 0, Percent(1.0) => 1):
      • This dictionary uses our custom Percent type as keys
      • JSON uses our StructUtils.lowerkey method to convert the keys to strings
  9. Raw JSON Inclusion:

    • json_properties::JSONText = JSONText("{\"key\": \"value\"}"):
      • The JSONText wrapper indicates this should be included as-is in the output
      • No escaping or processing is done; the string is inserted directly into the JSON
  10. Matrices and Multi-dimensional Arrays:

    • matrix::Matrix{Float64} = [1.0 2.0; 3.0 4.0]:
      • 2D array serialized as nested arrays in column-major order
  11. Ignoring Fields:

    • extra_field::Any = nothing &(json=(ignore=true,),):
      • The ignore=true field tag means this field will be completely excluded from serialization
      • Useful for internal fields that shouldn't be part of the JSON representation

Output Analysis

When we serialize this struct, we get a JSON string with all the specialized serialization rules applied:

{
  "id": 1,
  "name": null,
  "address": null,
  "rate": null,
  "franken_type": "a",
  "notsure": {"key": "value"},
  "monster": {"monster_type": "vampire", "num_victims": 10},
  "percent": 0.5,
  "birthdate": "2025/01/01",
  "percentages": {"1.0": 1, "0.0": 0},
  "json_properties": {"key": "value"},
  "matrix": [[1.0, 3.0], [2.0, 4.0]]
}

Some key observations:

  • extra_field is completely omitted due to the ignore tag
  • Field names are either their originals (id, name) or renamed versions (franken_type instead of type)
  • The nested monster field has custom serialization, producing a specialized format
  • The date is in the custom format we specified
  • Dictionary keys using our custom Percent type are properly converted to strings
  • The matrix is serialized in column-major order as nested arrays
  • The JSONText data is inserted directly without any additional processing

This example demonstrates how JSON.jl provides extensive control over JSON serialization at multiple levels: global type rules, field-specific customization, and overall serialization options.