Parsing JSON in Haskell With Aeson Step by Step
The first time I parsed a real API response in Haskell, the payload had 41 fields, three of them optional, one of them a timestamp in a format nobody documented, and a field called "type" that I could not use as a record name.

The first time I parsed a real API response in Haskell, the payload had 41 fields, three of them optional, one of them a timestamp in a format nobody documented, and a field called "type" that I could not use as a record name. The first attempt took an afternoon. The second it took twenty minutes, because I had stopped fighting Aeson and started working with it.
This tutorial walks through parsing JSON in Haskell with Aeson the way I now do it on production services: start with derived instances, move to hand written parsers only where the data forces you, and treat every parse failure as a message you can read. You need GHC 9.4 or newer and the aeson package at version 2.1 or later; everything else comes from base.
Why Aeson Is the Default Choice for Parsing JSON in Haskell
Around nine out of ten Haskell codebases I have reviewed depend on aeson directly, and nearly every web framework, from servant to scotty, speaks its types. Learning it well pays off everywhere.
Aeson gives you two type classes that matter: FromJSON for decoding and ToJSON for encoding. A value of type Value sits in the middle, representing any JSON document as objects, arrays, strings, numbers, booleans and null. Your job is to describe how a Value turns into your own type, and Aeson handles the bytes.
Step 1: Derive Your First Instance With Generics
Start with the cheapest possible version. Define a record, enable the DeriveGeneric and DeriveAnyClass extensions, and add deriving clauses for Generic, FromJSON and ToJSON. For a record such as User with fields userId, name and email, that is about six lines of code.
Then decode a ByteString with eitherDecode, not decode. The plain decode function returns Maybe, so a failure gives you Nothing and no clue. The eitherDecode function returns Either String, and the Left value tells you the exact path that failed, something like "Error in $.users[3].email: expected String, but encountered Null".
If you have read how the Maybe type replaces null in Haskell, you already know why Maybe is great for missing values. It is a poor fit for errors you need to explain.
Step 2: Match Real World Field Names
Real APIs rarely use camelCase names that match your record. You will see snake_case keys, reserved words, and prefixes. Aeson handles this through Options, which you pass to genericParseJSON instead of relying on the bare derived instance.
The three options I use most
- fieldLabelModifier: a function from your Haskell field name to the JSON key. Combine it with camelTo2 and an underscore to turn userId into user_id automatically.
- omitNothingFields: set to True so that encoding skips Maybe fields holding Nothing, instead of writing null.
- rejectUnknownFields: set to True during development to catch keys you forgot to model. Turn it off in production if the API adds fields without warning.
Step 3: Write a Manual Parser When Generics Run Out
Generic deriving stops being useful when the JSON shape and your type shape diverge. Common examples: a field that is sometimes a number and sometimes a string, a nested object you want to flatten, or a status string you want as a proper sum type.
Here you write parseJSON yourself with withObject. Inside it, use the .: operator for required keys and .:? for optional ones. For an Order type, the body reads like a list of facts: the id is required, the total is required, the coupon code is optional, and the status must be one of "pending", "paid" or "refunded".
For the status field, parse a Text first, then pattern match on it and call fail with a clear message for anything unexpected. That message flows straight into the eitherDecode error, so write it for the colleague who will see it at 3am. "unknown order status: shipped" beats "parse error" every time.
Step 4: Handle Optional, Missing and Null Fields Correctly
This is where most bugs hide, because JSON has two ways to say "nothing" and Haskell has one.
| JSON input | Using .: | Using .:? | Using .:! |
|---|---|---|---|
| Key present with value | Parses value | Just value | Just value |
| Key present with null | Fails unless type allows null | Nothing | Fails |
| Key missing | Fails | Nothing | Nothing |
Most of the time .:? is what you want. Reach for .:! only when an explicit null means something different from a missing key, for example in PATCH requests where null means "clear this field" and absence means "leave it alone". When you need a default instead of Maybe, chain the result with the .!= operator and a fallback value.
The Advice I Disagree With: Parse Everything Into Value First
A popular suggestion in forum threads is to decode into Value, poke around with lens style accessors, and only build typed records later. In my experience it is how you end up with a codebase full of partial lookups that crash six months later when a key gets renamed.
Parsing into Value is fine for exploration in GHCi or a one off script. For anything that ships, write the typed parser up front. The type is documentation, the compiler checks every use, and the error messages point at the JSON path. If a field is genuinely free form, keep that one field as Value inside an otherwise typed record.
The same reasoning applies to wrapping identifiers. A raw Int for a user id and a raw Int for an order id will decode identically, which is exactly the problem. Wrap them, as described in our piece on Haskell newtype patterns, and derive FromJSON through the newtype so the JSON stays a plain number.
Step 5: Test Your Parsers With Real Payloads
Unit tests for JSON parsers are cheap and catch real regressions. My routine has three parts:
- Save two or three real API responses as test fixtures, with personal data replaced.
- Write a golden test that decodes each fixture and asserts a Right result with the fields you care about.
- Add a round trip property with QuickCheck: encoding then decoding any generated value should return the original. This catches mismatched field modifiers between ToJSON and FromJSON within seconds.
When a test fails, the eitherDecode message plus the compiler output usually explain everything. If the type errors from a custom parser look intimidating, our guide to reading GHC error messages breaks down the common ones line by line.
FAQ
Should I use Generic deriving or Template Haskell for Aeson instances?
Both produce the same behaviour. Template Haskell with deriveJSON can compile slightly faster code for very large records, but Generic deriving is simpler and avoids splice ordering issues. Start with Generic and switch only if profiling shows decoding is a bottleneck.
Why does Aeson 2 use Key instead of Text for object keys?
Version 2.0 introduced the Key type to protect against hash flooding attacks on object maps. In everyday code the change is small: use fromText or the OverloadedStrings extension to write keys as string literals, and the rest of your parser stays the same.
How do I parse a JSON array of objects into a list?
If your element type has a FromJSON instance, a list of that type already has one. Decode straight into a list, or use .: on a key whose value is an array, and Aeson applies your element parser to each item.
The Practical Takeaway
Begin every new integration with a derived instance and eitherDecode, then let the data tell you where to customise. Add Options for naming, write a manual parser only for fields that do not fit, pick .:? unless null carries meaning, and keep fixtures under test. Follow that order and parsing JSON in Haskell with Aeson becomes routine work instead of an afternoon lost to a single 41 field response.
More in Tutorials
Tutorials
Haskell Monad Transformers Step by Step With a Real App
You have a function that needs configuration, can fail with a typed error, and has to print to the console, and suddenly every...
Tutorials
How to Read GHC Error Messages Without Panicking
The first time most people compile a twenty line Haskell program, GHC answers with forty lines of complaint.
Tutorials
Fixing Cabal's "Could Not Resolve Dependencies" Error
You clone a Haskell project that built fine for its author last spring, run cabal build, and get a wall of text that starts...