JSON in Julia: JSON3.jl, JSON.jl, StructTypes & DataFrame Integration

Last updated:

Julia handles JSON through two main packages: JSON3.jl for high-performance allocation-free parsing, and JSON.jl for flexible Dict-based output. JSON3.read(str) parses a JSON string into a read-only object tree 3-5× faster than JSON.jl by avoiding full materialization — ideal for large API responses and data pipelines. JSON3.write(obj) serializes Julia dicts, vectors, structs, and NamedTuples to JSON. For typed parsing, registering a struct with StructTypes.jl enables JSON3.read(str, MyStruct) to deserialize directly into a custom Julia struct with zero allocation. Julia's NamedTuples serialize naturally: JSON3.write((name="Alice", age=30)) produces {"{"}"name":"Alice","age":30{"}"}. JSON3 also integrates with DataFrames.jl — JSON3.read(str, DataFrame) loads a JSON array of objects directly into a DataFrame. This guide covers JSON3.jl vs JSON.jl, struct-based parsing with StructTypes, NamedTuple serialization, reading JSON files, DataFrames integration, and HTTP API JSON handling with HTTP.jl.

JSON3.read and JSON3.write: High-Performance Parsing

JSON3.jl is the recommended package for production JSON work in Julia. JSON3.read(str) returns a lazy, read-only object where JSON objects become JSON3.Object and JSON arrays become JSON3.Array. Values are extracted on access rather than all at once, which is why JSON3 is 3-5× faster than JSON.jl for large files — unaccessed branches are never allocated. JSON3.write(obj) accepts dicts, vectors, NamedTuples, and any StructTypes-registered struct, producing a compact JSON string. Use the indent keyword for pretty printing.

# Install once in the Julia REPL:
# ] add JSON3 StructTypes

using JSON3

# ── Parsing a JSON string ──────────────────────────────────────
str = """{"name":"Alice","age":30,"scores":[95,87,92]}"""

obj = JSON3.read(str)
# JSON3.Object — read-only, lazy
println(obj.name)        # "Alice"
println(obj.age)         # 30
println(obj.scores[1])   # 95  (Julia arrays are 1-indexed)

# ── Accessing nested objects ───────────────────────────────────
nested = JSON3.read("""{"user":{"id":1,"tags":["julia","json"]}}""")
println(nested.user.id)       # 1
println(nested.user.tags[2])  # "json"

# ── Serializing to JSON ────────────────────────────────────────
data = Dict("name" => "Bob", "scores" => [88, 91])
json_str = JSON3.write(data)
# {"name":"Bob","scores":[88,91]}

# Pretty print with indent
pretty = JSON3.write(data; indent=2)
# {
#   "name": "Bob",
#   "scores": [
#     88,
#     91
#   ]
# }

# ── Serializing NamedTuples ────────────────────────────────────
nt = (name="Alice", age=30, active=true)
JSON3.write(nt)
# {"name":"Alice","age":30,"active":true}

# ── Serializing vectors ────────────────────────────────────────
items = [Dict("id" => i, "val" => i * 2) for i in 1:3]
JSON3.write(items)
# [{"id":1,"val":2},{"id":2,"val":4},{"id":3,"val":6}]

# ── Typed reading: parse directly into a Vector{String} ───────
arr = JSON3.read("""["apple","banana","cherry"]""", Vector{String})
# 3-element Vector{String}

# ── Reading from an IO object (file or HTTP body) ─────────────
# open("data.json") do f
#   obj = JSON3.read(f)
# end

JSON3.read is non-allocating for the object structure itself — the returned JSON3.Object points into the original string buffer rather than copying values. This means you should keep the source string alive as long as you are using the parsed object. If you need a fully materialized mutable copy, call copy(obj) or convert to a Dict explicitly. For write operations, JSON3.write accepts a second argument of an IO handle, enabling streaming output directly to a file or network socket without building an intermediate string.

JSON.jl: Dict-Based Flexible Parsing

JSON.jl is the older, simpler JSON package for Julia. JSON.parse(str) returns a fully materialized Dict{"{"}String, Any{"}"} for JSON objects and Vector{"{"}Any{"}"} for JSON arrays. This output is mutable and easy to work with using standard Julia dict and array operations, making JSON.jl a good choice for interactive scripts, small payloads, and cases where you want to modify the parsed result. JSON.json(obj) serializes Julia values to a compact JSON string, and JSON.print(io, obj, indent) writes formatted JSON to any IO target.

# Install once:
# ] add JSON

using JSON

# ── Parsing into Dict{String, Any} ────────────────────────────
str = """{"name":"Alice","age":30,"scores":[95,87,92]}"""

obj = JSON.parse(str)
# Dict{String, Any} — mutable
println(obj["name"])       # "Alice"
println(obj["age"])        # 30
println(obj["scores"][1])  # 95

# ── Mutating the result ────────────────────────────────────────
obj["age"] = 31           # valid — Dict is mutable
push!(obj["scores"], 99)  # add a score

# ── Parsing a JSON array ───────────────────────────────────────
arr_str = """[{"id":1},{"id":2},{"id":3}]"""
arr = JSON.parse(arr_str)
# Vector{Any} — each element is a Dict{String, Any}

for item in arr
  println(item["id"])  # 1, 2, 3
end

# ── Nested access with get() for safe default ─────────────────
nested = JSON.parse("""{"user":{"name":"Bob","role":null}}""")
role = get(nested["user"], "role", "guest")
# "guest" (because role is null/nothing)

# ── Serializing with JSON.json ─────────────────────────────────
data = Dict("status" => "ok", "count" => 42)
JSON.json(data)
# '{"status":"ok","count":42}'

# ── Pretty print to stdout ─────────────────────────────────────
JSON.print(stdout, data, 2)
# {
#   "status": "ok",
#   "count": 42
# }

# ── Writing to a file ─────────────────────────────────────────
# open("output.json", "w") do f
#   JSON.print(f, data, 4)
# end

# ── Reading from a file ───────────────────────────────────────
# open("data.json") do f
#   obj = JSON.parse(f)
# end

JSON.jl performs a full parse on every call — the entire JSON string is tokenized and the complete object tree is allocated in memory. For a 10 MB JSON file this typically takes 50-200 ms and allocates a proportional amount of memory. If you find JSON.jl too slow for your use case, migrate to JSON3.jl: the API is nearly identical for basic cases (JSON3.read replaces JSON.parse, JSON3.write replaces JSON.json), and you gain the 3-5× speed improvement without changing much code. JSON.jl does not support typed struct deserialization or DataFrame integration, both of which are JSON3.jl features.

StructTypes: Parsing JSON Directly into Julia Structs

StructTypes.jl is the bridge between JSON3.jl and custom Julia structs. By declaring a StructType for your struct, you enable JSON3.read to deserialize JSON directly into that struct type with zero intermediate allocations — no Dict, no Any, no manual field assignment. The most common declaration is StructTypes.StructType(::Type{"{"}MyStruct{"}"}) = StructTypes.Struct(), which treats every struct field as a required JSON key. Use Mutable() instead of Struct() for mutable structs declared with mutable struct.

using JSON3, StructTypes

# ── Define an immutable struct ────────────────────────────────
struct User
  id::Int
  name::String
  email::String
  active::Bool
end

# Register with StructTypes — enables JSON3 deserialization
StructTypes.StructType(::Type{User}) = StructTypes.Struct()

# ── Deserialize a JSON object into User ───────────────────────
json_str = """{"id":1,"name":"Alice","email":"alice@example.com","active":true}"""
user = JSON3.read(json_str, User)
# User(1, "Alice", "alice@example.com", true)

println(user.name)    # "Alice"
println(user.active)  # true

# ── Mutable struct (use Mutable() instead of Struct()) ────────
mutable struct Product
  id::Int
  name::String
  price::Float64
  tags::Vector{String}
end

StructTypes.StructType(::Type{Product}) = StructTypes.Mutable()

product = JSON3.read(
  """{"id":42,"name":"Widget","price":9.99,"tags":["sale","new"]}""",
  Product
)
# Product(42, "Widget", 9.99, ["sale", "new"])

# ── Nested struct deserialization ──────────────────────────────
struct Address
  street::String
  city::String
  country::String
end
StructTypes.StructType(::Type{Address}) = StructTypes.Struct()

struct Customer
  id::Int
  name::String
  address::Address
end
StructTypes.StructType(::Type{Customer}) = StructTypes.Struct()

json = """
{
  "id": 7,
  "name": "Bob",
  "address": {"street": "1 Main St", "city": "Springfield", "country": "US"}
}
"""
customer = JSON3.read(json, Customer)
println(customer.address.city)  # "Springfield"

# ── Optional fields with Union{T, Nothing} ────────────────────
struct Profile
  username::String
  bio::Union{String, Nothing}  # accepts null or string
  age::Union{Int, Nothing}
end
StructTypes.StructType(::Type{Profile}) = StructTypes.Struct()

p = JSON3.read("""{"username":"alice","bio":null,"age":28}""", Profile)
println(p.bio)  # nothing

# ── Serialize a struct back to JSON ───────────────────────────
# JSON3.write works on any StructTypes-registered struct
JSON3.write(user)
# {"id":1,"name":"Alice","email":"alice@example.com","active":true}

StructTypes.jl supports several StructType declarations beyond Struct() and Mutable(). StructTypes.DictType() treats the struct like a dict (useful for wrapper types). StructTypes.ArrayType() treats the struct like an array. StructTypes.StringType() serializes to and from a string representation. For field name mapping — for example, if the JSON uses snake_case but Julia uses camelCase — override StructTypes.names(::Type{"{"}MyStruct{"}"}) = ((:juliaField, :json_field),) to declare the JSON name for each field.

NamedTuples and Custom Types as JSON

Julia's NamedTuples are first-class JSON objects in JSON3.jl — no registration or configuration required. JSON3.write((key="value", num=42)) produces {"{"}"key":"value","num":42{"}"} directly. NamedTuples are immutable, zero-allocation, and naturally composable, making them the lightest way to build ad-hoc JSON structures in Julia. Nested NamedTuples produce nested JSON objects. Arrays of NamedTuples produce JSON arrays of objects.

using JSON3

# ── NamedTuple → JSON object ───────────────────────────────────
JSON3.write((name="Alice", age=30))
# {"name":"Alice","age":30}

# ── Nested NamedTuples ────────────────────────────────────────
payload = (
  user = (id=1, name="Alice"),
  meta = (version="1.0", timestamp="2026-05-28T00:00:00Z")
)
JSON3.write(payload)
# {"user":{"id":1,"name":"Alice"},"meta":{"version":"1.0","timestamp":"2026-05-28T00:00:00Z"}}

# ── Vector of NamedTuples → JSON array of objects ─────────────
records = [
  (id=1, name="Alice", score=95.5),
  (id=2, name="Bob",   score=87.0),
  (id=3, name="Carol", score=92.3),
]
JSON3.write(records)
# [{"id":1,"name":"Alice","score":95.5},{"id":2,"name":"Bob","score":87.0},{"id":3,"name":"Carol","score":92.3}]

# ── Enums with JSON3 ──────────────────────────────────────────
@enum Status active=1 inactive=2 pending=3

# Enums serialize as their integer value by default
JSON3.write(active)  # 1

# For string serialization, use StructTypes.StringType()
using StructTypes
StructTypes.StructType(::Type{Status}) = StructTypes.NumberType()

# Or register as StringType to get "active", "inactive", etc.
# StructTypes.StructType(::Type{Status}) = StructTypes.StringType()

# ── Pairs and generic iterables ────────────────────────────────
JSON3.write(pairs(Dict("a" => 1, "b" => 2)))
# Produces a JSON array of [key, value] pairs

# ── Tuple (positional) → JSON array ───────────────────────────
JSON3.write((1, "hello", true))
# [1,"hello",true]

# ── Mixing types in a JSON array ──────────────────────────────
mixed = Any[1, "two", Dict("key" => true)]
JSON3.write(mixed)
# [1,"two",{"key":true}]

# ── Custom serialization with StructTypes ─────────────────────
struct Money
  amount::Int    # stored in cents
  currency::String
end
StructTypes.StructType(::Type{Money}) = StructTypes.Struct()
# Serializes as {"amount":999,"currency":"USD"}
# Add field names override to rename or skip fields if needed

One subtlety with NamedTuples is that field order in the JSON output matches the order fields are declared in the NamedTuple, which is deterministic in Julia. This makes NamedTuples a reliable choice when you need predictable JSON key ordering — for example, when computing a hash or signature over the JSON output. Regular Julia Dict does not guarantee key order, so for canonical JSON use NamedTuples or sort the keys explicitly before serialization.

Reading and Writing JSON Files

Both JSON3.jl and JSON.jl accept IO handles directly, enabling streaming reads from files without first loading the entire file into a string. This matters for JSON files larger than a few megabytes — loading a 100 MB file as a string and then parsing it doubles peak memory usage compared to streaming. The Julia open ... do f ... end pattern ensures the file handle closes automatically even if an error occurs during parsing.

using JSON3, StructTypes

struct Record
  id::Int
  name::String
  value::Float64
end
StructTypes.StructType(::Type{Record}) = StructTypes.Struct()

# ── Read a JSON file into a generic object ─────────────────────
obj = open("data.json") do f
  JSON3.read(f)
end
# JSON3.Object — access fields with dot notation

# ── Read a JSON file into a typed struct ──────────────────────
record = open("record.json") do f
  JSON3.read(f, Record)
end
# Record instance

# ── Read a JSON array file into a typed vector ────────────────
records = open("records.json") do f
  JSON3.read(f, Vector{Record})
end
# Vector{Record} with all elements

# ── Write a struct to a JSON file (compact) ───────────────────
r = Record(1, "Alice", 9.99)
open("output.json", "w") do f
  JSON3.write(f, r)
end
# writes {"id":1,"name":"Alice","value":9.99}

# ── Write with indentation (pretty print) ─────────────────────
open("pretty.json", "w") do f
  JSON3.write(f, r; indent=2)
end
# {
#   "id": 1,
#   "name": "Alice",
#   "value": 9.99
# }

# ── Append multiple JSON objects as JSONL (one per line) ──────
records_to_write = [Record(i, "name_$i", Float64(i)) for i in 1:100]
open("records.jsonl", "w") do f
  for rec in records_to_write
    println(f, JSON3.write(rec))
  end
end
# Each line: {"id":1,"name":"name_1","value":1.0}

# ── Reading JSONL files (newline-delimited JSON) ───────────────
records_loaded = Record[]
open("records.jsonl") do f
  for line in eachline(f)
    push!(records_loaded, JSON3.read(line, Record))
  end
end
length(records_loaded)  # 100

# ── Using JSON.jl for file I/O ─────────────────────────────────
# using JSON
# obj = open("data.json") do f; JSON.parse(f); end
# open("out.json", "w") do f; JSON.print(f, obj, 2); end

For reading large JSON files (hundreds of megabytes), the IO-streaming approach shown above is the right pattern. If you need true streaming JSON parsing where individual records are processed as they are read — without loading the full array into memory — consider using JSON3.read on the IO handle with a typed element and iterating, or the LazyJSON.jl package which provides a fully lazy parser. The JSONL (newline-delimited JSON) pattern shown above is particularly efficient for large datasets because each line is a complete, self-contained JSON document that can be parsed independently.

JSON and DataFrames: Loading API Data

JSON3.jl integrates directly with DataFrames.jl through a type piracy extension: passing DataFrame as the type argument to JSON3.read triggers a specialization that converts a JSON array of objects into a DataFrame where each key becomes a column. This is the most ergonomic way to load JSON API responses into tabular form for analysis in Julia.

# ] add JSON3 DataFrames

using JSON3, DataFrames

# ── JSON array of objects → DataFrame in one call ─────────────
json_str = """
[
  {"id": 1, "name": "Alice", "score": 95.5, "active": true},
  {"id": 2, "name": "Bob",   "score": 87.0, "active": false},
  {"id": 3, "name": "Carol", "score": 92.3, "active": true}
]
"""

df = JSON3.read(json_str, DataFrame)
# 3×4 DataFrame
#  Row │ id     name    score    active
#      │ Int64  String  Float64  Bool
# ─────┼────────────────────────────────
#    1 │     1  Alice    95.5    true
#    2 │     2  Bob      87.0    false
#    3 │     3  Carol    92.3    true

println(names(df))    # ["id", "name", "score", "active"]
println(df[1, :name]) # "Alice"

# Filter rows with standard DataFrame operations
active_df = df[df.active .== true, :]  # Alice and Carol

# ── Reading from a JSON file into DataFrame ────────────────────
df2 = open("records.json") do f
  JSON3.read(f, DataFrame)
end

# ── Handling nested JSON (unwrap first) ───────────────────────
wrapped = """{"data":[{"id":1,"val":10},{"id":2,"val":20}],"total":2}"""
parsed = JSON3.read(wrapped)
# Access the nested array and convert
inner_json = JSON3.write(parsed.data)
df3 = JSON3.read(inner_json, DataFrame)
# 2×2 DataFrame

# ── DataFrame → JSON array of objects ────────────────────────
# Convert DataFrame rows to NamedTuples and write
rows = [NamedTuple(df[i, :]) for i in 1:nrow(df)]
json_out = JSON3.write(rows)
# [{"id":1,"name":"Alice","score":95.5,"active":true}, ...]

# ── Using CSV.jl as an alternative path ───────────────────────
# For very large JSON arrays, consider writing to CSV first:
# using CSV
# CSV.write("data.csv", df)

# ── JSON null → missing in DataFrames ─────────────────────────
with_nulls = """
[
  {"id":1,"value":42},
  {"id":2,"value":null},
  {"id":3,"value":99}
]
"""
df_nulls = JSON3.read(with_nulls, DataFrame)
# Column "value" has type Union{Missing, Int64}
println(df_nulls.value[2])  # missing
println(skipmissing(df_nulls.value) |> sum)  # 141

The DataFrame integration handles JSON null values by converting them to Julia's missing, so columns with any null in the JSON array get type Union{"{"}Missing, T{"}"}. This integrates with Julia's statistics functions via skipmissing() and coalesce(). For writing a DataFrame back to JSON, converting each row to a NamedTuple is the most direct approach, as shown above. Alternatively, you can export to CSV using CSV.jl and process the file with external tools. The entire DataFrame-to-JSON round-trip is lossless for columns with numeric, string, and boolean types.

HTTP API JSON with HTTP.jl

HTTP.jl is the standard HTTP client for Julia and pairs naturally with JSON3.jl for consuming REST APIs. HTTP.jl returns response bodies as Vector{"{"}UInt8{"}"}, so convert to a string with String(response.body) before passing to JSON3.read — or use the String IO conversion directly. For POST requests with a JSON body, serialize with JSON3.write and set the Content-Type header to application/json.

# ] add HTTP JSON3 StructTypes

using HTTP, JSON3, StructTypes

# ── Define response struct ────────────────────────────────────
struct GithubUser
  login::String
  id::Int
  name::Union{String, Nothing}
  public_repos::Int
  followers::Int
end
StructTypes.StructType(::Type{GithubUser}) = StructTypes.Struct()

# ── GET request → parse JSON response ────────────────────────
response = HTTP.get(
  "https://api.github.com/users/JuliaLang",
  ["Accept" => "application/json", "User-Agent" => "Julia/1.10"]
)

if response.status == 200
  user = JSON3.read(String(response.body), GithubUser)
  println(user.login)        # "JuliaLang"
  println(user.public_repos) # number of public repos
end

# ── POST request with a JSON body ────────────────────────────
payload = (title="Test", body="Hello from Julia", userId=1)
json_body = JSON3.write(payload)

post_resp = HTTP.post(
  "https://jsonplaceholder.typicode.com/posts",
  ["Content-Type" => "application/json"],
  json_body
)

created = JSON3.read(String(post_resp.body))
println(created.id)    # 101 (new post ID from the API)

# ── Error handling with HTTP status codes ─────────────────────
function fetch_json(url::String)
  try
    resp = HTTP.get(url; status_exception=false)
    if resp.status == 200
      return JSON3.read(String(resp.body))
    elseif resp.status == 404
      error("Resource not found: $url")
    else
      error("HTTP $(resp.status): $(String(resp.body))")
    end
  catch e
    if e isa HTTP.ConnectionError
      error("Network error: $(e.message)")
    end
    rethrow(e)
  end
end

# ── Paginated API: fetch all pages ────────────────────────────
function fetch_all_pages(base_url::String)
  all_records = []
  page = 1
  while true
    resp = HTTP.get("$base_url?page=$page&per_page=100")
    data = JSON3.read(String(resp.body))
    items = data.items  # adjust to your API's response shape
    isempty(items) && break
    append!(all_records, items)
    page += 1
    length(items) < 100 && break  # last page
  end
  return all_records
end

# ── Loading API data directly into a DataFrame ────────────────
resp = HTTP.get("https://api.example.com/records")
df = JSON3.read(String(resp.body), DataFrame)
# Complete pipeline: HTTP request → JSON parse → DataFrame

For production API clients in Julia, consider adding retry logic via HTTP.jl's retry parameter and setting timeouts with the connect_timeout and readtimeout options. When working with APIs that return large JSON responses (multiple megabytes), pass the HTTP response IO directly to JSON3.read without converting to a string first: JSON3.read(IOBuffer(response.body)) — this avoids one copy of the data. For authenticated APIs, pass bearer tokens in the Authorization header: ["Authorization" {"=>"} "Bearer $token", "Content-Type" {"=>"} "application/json"].

FAQ

How do I parse a JSON string in Julia?

The two most common options are JSON3.jl and JSON.jl. With JSON3.jl, call JSON3.read(str) to parse any JSON string into a read-only, tree-structured Julia object — arrays become JSON3.Array, objects become JSON3.Object, and scalar values map to their native Julia types (String, Int64, Float64, Bool, Nothing). With JSON.jl, call JSON.parse(str) to get a mutable Dict{"{"}String, Any{"}"} for objects and Vector{"{"}Any{"}"} for arrays, which is easier to navigate with standard Julia dict syntax. JSON3.jl is 3-5× faster than JSON.jl for large payloads because it avoids materializing the full object tree. Install either package with Pkg.add("JSON3") or Pkg.add("JSON") in the Julia REPL, then import with using JSON3 or using JSON.

What is the difference between JSON3.jl and JSON.jl?

JSON3.jl is the modern, high-performance choice: JSON3.read(str) returns a read-only, lazy object tree that avoids materializing data not accessed, making it 3-5× faster than JSON.jl for large files. JSON3.jl integrates with StructTypes.jl for zero-allocation struct deserialization and with DataFrames.jl for direct loading of JSON arrays into tabular data. JSON.jl is the older, simpler package: JSON.parse(str) returns a fully materialized Dict{"{"}String, Any{"}"} that is easy to work with using standard Julia dict and array operations. JSON.jl does not require StructTypes annotations and is sufficient for small payloads or quick scripts. For production pipelines processing large JSON files, prefer JSON3.jl. For interactive exploration or simple tooling, JSON.jl is lighter to use.

How do I serialize a Julia struct to JSON?

With JSON3.jl, first register the struct type with StructTypes.jl by declaring StructTypes.StructType(::Type{"{"}MyStruct{"}"}) = StructTypes.Struct(). After registration, JSON3.write(instance) serializes the struct to a JSON string in one call. Field names become JSON keys exactly as defined in the struct. JSON3.write works on any value — plain dicts, vectors, NamedTuples, and registered structs all serialize correctly. For JSON.jl, use JSON.json(obj) to serialize. It handles Dict, Vector, and basic scalar types automatically, but custom structs require implementing a method that builds a Dict representation. NamedTuples serialize to JSON objects in both packages without any extra configuration — JSON3.write((name="Alice", age=30)) produces {"{"}"name":"Alice","age":30{"}"}.

How do I parse a JSON array into a Julia vector?

JSON3.jl provides direct typed array deserialization in one call: JSON3.read(str, Vector{"{"}MyStruct{"}"}) parses a JSON array and returns a fully typed Julia vector where each element has been deserialized into MyStruct. This requires MyStruct to be registered with StructTypes.jl first. For a JSON array of primitives, JSON3.read(str, Vector{"{"}Int{"}"}) or JSON3.read(str, Vector{"{"}String{"}"}) works without any registration. With JSON.jl, JSON.parse(str) on a JSON array returns a Vector{"{"}Any{"}"} — you then iterate and construct your objects manually. The JSON3.jl typed approach eliminates the manual conversion loop and avoids intermediate allocations, making it the preferred method for deserializing JSON arrays into typed Julia collections in data pipelines processing thousands of records.

How do I load JSON API data into a Julia DataFrame?

JSON3.jl integrates directly with DataFrames.jl. If your JSON is an array of objects — the standard API response shape — call JSON3.read(str, DataFrame) to deserialize the entire array into a DataFrame in one step, where each JSON object key becomes a column. Install both packages first: Pkg.add("JSON3") and Pkg.add("DataFrames"). For HTTP API responses, combine HTTP.jl with JSON3.jl: response = HTTP.get(url); df = JSON3.read(String(response.body), DataFrame). If the JSON array is nested inside a wrapper object like {"{"}"data": [...]{"}"}, parse the outer object first with JSON3.read(str), then serialize the nested array and parse again as DataFrame. This approach handles hundreds of thousands of records efficiently and preserves column types inferred from JSON values.

How do I read a JSON file in Julia?

With JSON3.jl, use open() with JSON3.read: open("data.json") do f; JSON3.read(f); end. This streams the file directly without loading the entire content into a string first, which is more memory-efficient for files larger than a few megabytes. You can also read the whole file as a string first: JSON3.read(read("data.json", String)). For typed deserialization from a file, pass the target type: open("records.json") do f; JSON3.read(f, Vector{"{"}MyStruct{"}"}); end. With JSON.jl, the equivalent is open("data.json") do f; JSON.parse(f); end. For very large JSON files (hundreds of megabytes), always prefer the IO-handle approach over reading the entire file into a string first, as it reduces peak memory usage by half.

How do I handle missing or null values in Julia JSON?

JSON null values map to Julia's nothing (of type Nothing) when parsed with JSON3.jl, and to nothing with JSON.jl as well. In typed struct deserialization, declare a field as Union{"{"}T, Nothing{"}"} to accept both a value and null: bio::Union{"{"}String, Nothing{"}"}. JSON3.read will set the field to nothing when the JSON field is null. For DataFrames, JSON null becomes missing (Julia's missing value for data analysis), not nothing — DataFrames uses Missing for absent data to integrate with statistical functions like skipmissing() and coalesce(). When writing Julia nothing back to JSON, JSON3.write(nothing) produces null. The distinction between nothing and missing matters when loading JSON into DataFrames.

How do I write a JSON file from Julia data?

With JSON3.jl, write to a file by opening it and passing the IO handle to JSON3.write: open("output.json", "w") do f; JSON3.write(f, data); end. This streams the JSON directly to disk. For formatted (pretty-printed) output, use the indent keyword: JSON3.write(f, data; indent=2). With JSON.jl, use JSON.print(f, data, 4) for 4-space indentation or JSON.print(f, data) for compact output. To serialize a vector of structs to a JSONL file (one JSON object per line, used by OpenAI and Hugging Face), iterate and write each line: for item in records; println(f, JSON3.write(item)); end. Always use the do-block syntax for file handles to ensure the file closes and flushes even if an error occurs during serialization.

Further reading and primary sources