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) -> StringThe 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 type | JSON representation |
|---|---|
Nothing | null |
Bool | true or false |
Number | Numeric value (integer or floating point) |
AbstractString | String with escaped characters |
AbstractDict/NamedTuple | Object ({}) |
AbstractVector/Tuple/Set | Array ([]) |
| Custom structs | Object ({}) with fields as keys |
JSONText | Raw 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 indentationExample 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}) = trueOr 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}
endSpecial 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 toBase.@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
The
AbstractMonsterType Hierarchy:- We define an abstract type
AbstractMonsterwith two concrete subtypes:DraculaandWerewolf - Each type contains type-specific data (number of victims vs. witching hour)
- We define an abstract type
Custom Numeric Type:
Percentis a custom numeric type that wraps aFloat64- We provide two serialization methods:
JSON.lower(x::Percent) = x.value: This tells JSON how to serialize aPercentvalue (convert to the underlying Float64)StructUtils.lowerkey(x::Percent) = string(x.value): This handles when aPercentis used as a dictionary key
The
FrankenStruct:- Created with
@noargmaking it a mutable struct that can be default constructed likeFrankenStruct()
- Created with
Field-Level Serialization Control
Let's examine each field of FrankenStruct in detail:
Basic Fields:
id::Int: Standard integer field (initialized explicitly to 1)name::String: Intentionally left uninitialized to demonstrate#undefserialization
Null Handling and Unions:
address::Union{Nothing, String} = nothing: Demonstrates howNothingvalues are serializedrate::Union{Missing, Float64} = missing: Shows howMissingvalues are serialized (both becomenullin JSON)
Field Renaming with Tags:
type::Symbol = :a &(json=(name="franken_type",),):- The
nametag changes the output JSON key from"type"to"franken_type" - The value
:ais automatically serialized as the string"a"through a defaultlowermethod for symbols
- The
Any Type:
notsure::Any = JSON.Object("key" => "value"): Shows how JSON handles arbitrary types
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
lowerfield 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.lowermethod, this approach only applies when this specific field is serialized
- This demonstrates field-specific custom serialization using the
Custom Numeric Type:
percent::Percent = Percent(0.5): Uses the globalJSON.lowerwe defined to serialize as0.5
Custom Date Formatting:
birthdate::Date = Date(2025, 1, 1) &(json=(dateformat="yyyy/mm/dd",),):- The
dateformatfield tag controls how the date is formatted - Instead of ISO format (
"2025-01-01"), it's serialized as"2025/01/01"
- The
Dictionary with Custom Keys:
percentages::Dict{Percent, Int} = Dict{Percent, Int}(Percent(0.0) => 0, Percent(1.0) => 1):- This dictionary uses our custom
Percenttype as keys - JSON uses our
StructUtils.lowerkeymethod to convert the keys to strings
- This dictionary uses our custom
Raw JSON Inclusion:
json_properties::JSONText = JSONText("{\"key\": \"value\"}"):- The
JSONTextwrapper indicates this should be included as-is in the output - No escaping or processing is done; the string is inserted directly into the JSON
- The
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
Ignoring Fields:
extra_field::Any = nothing &(json=(ignore=true,),):- The
ignore=truefield tag means this field will be completely excluded from serialization - Useful for internal fields that shouldn't be part of the JSON representation
- The
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_fieldis completely omitted due to theignoretag- Field names are either their originals (
id,name) or renamed versions (franken_typeinstead oftype) - The nested
monsterfield has custom serialization, producing a specialized format - The date is in the custom format we specified
- Dictionary keys using our custom
Percenttype are properly converted to strings - The matrix is serialized in column-major order as nested arrays
- The
JSONTextdata 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.