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 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 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 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
AbstractMonster
Type Hierarchy:- We define an abstract type
AbstractMonster
with two concrete subtypes:Dracula
andWerewolf
- Each type contains type-specific data (number of victims vs. witching hour)
- We define an abstract type
Custom Numeric Type:
Percent
is a custom numeric type that wraps aFloat64
- We provide two serialization methods:
JSON.lower(x::Percent) = x.value
: This tells JSON how to serialize aPercent
value (convert to the underlying Float64)StructUtils.lowerkey(x::Percent) = string(x.value)
: This handles when aPercent
is used as a dictionary key
The
FrankenStruct
:- Created with
@noarg
making 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#undef
serialization
Null Handling and Unions:
address::Union{Nothing, String} = nothing
: Demonstrates howNothing
values are serializedrate::Union{Missing, Float64} = missing
: Shows howMissing
values are serialized (both becomenull
in JSON)
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 defaultlower
method 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
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
- This demonstrates field-specific custom serialization using the
Custom Numeric Type:
percent::Percent = Percent(0.5)
: Uses the globalJSON.lower
we defined to serialize as0.5
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"
- 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
Percent
type as keys - JSON uses our
StructUtils.lowerkey
method to convert the keys to strings
- This dictionary uses our custom
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
- 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=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
- 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_field
is completely omitted due to theignore
tag- Field names are either their originals (
id
,name
) or renamed versions (franken_type
instead oftype
) - 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.