Designing a Clean JSON-to-Model Pipeline in Python
- Mic

- Jan 8
- 6 min read
In many Python projects, especially those involving APIs, data ingestion, or automation pipelines, a surprisingly large portion of complexity comes from one simple task:
Turning messy JSON into clean Python objects you can trust.
The difficulty isn’t really the parsing itself — it’s structure, consistency, and type safety.
Real-world payloads often contain:
deeply nested dictionaries
optional or missing fields
inconsistent shapes across records
numbers encoded as strings
lists of objects that should really be lists of primitives
If you don’t establish a clear boundary early, your entire codebase becomes defensive:
Such an approach does not scale well and it becomes an absolute mess very quickly.
A much cleaner architecture is to split the problem into distinct stages:
Select what you need from messy JSON (JMESPath)
Reshape it into a predictable structure (glom)
Materialize it into typed Python objects (dacite / cattrs)
Validate and fail loudly when reality disagrees
This creates a maintainable pipeline where each tool has a clear responsibility. Especially if the overall schema of your JSON data changes you will know very quickly where it changed and you can act accordingly. Note that none of this is technically necessary, you could easily write all of these manually with the same outcome. The presented packages are meant to make it more readable and easier to track down errors if your data schema changes.

The Problem: JSON Is a Loveable Liar
We will skip how the JSON is obtained for this post. It could come from an API or you directly read it in from a file. For now we assume that it looks as follows
At first glance, this looks fine. But it has many of the problems we identified above. What you want is something like:
User(id=42, full_name="Me Not you", email="menotyou@example.com", roles=["admin","billing"])
Order(id="A100", total=19.9, items=[...])
That means we need to transform both shape and types.
Instead of writing a custom parsing logic everywhere, we’ll build a layered pipeline.
“Select” with JMESPath
JMESPath is a query language for JSON. If you’ve used SQL for databases, JMESPath plays a similar role for JSON documents. It allows you to declaratively specify:
what fields you want
how to traverse nested objects
how to project lists
One of its biggest advantages is clarity. You can inspect payloads without writing loops or conditionals.
Simple Example using JMESPath
For example, if you want to pluck a few fields
Running this would give us
{'request_id': 'req_3524', 'user_id': '21', 'first': 'Me', 'last': 'Not you', 'email': 'menotyou@example.com', 'role_names': ['admin', 'billing'], 'order_ids': ['A100', 'A101']}This is extremely useful when you’re exploring unfamiliar data or debugging upstream changes.
Advanced Example — search for expressions
JMESPath isn’t a full programming language, but it can do useful list operations.
Output here would be
[{'id': 'A100', 'currency': 'AUD', 'item_count': 2}, {'id': 'A101', 'currency': 'AUD', 'item_count': 0}]As expected, we look for all lists that are named orders and create entries including id, currency, and item_count.
JMESPath excels at selection and projection, but once transformations become more structural — renaming fields, inserting defaults, flattening nested objects — the expressions become harder to maintain. That’s where glom comes in.
“Reshape” with glom
If JMESPath answers “Where is the data?”, then glom answers:
“What structure do I want instead?”
Glom uses specifications (specs) that describe how to transform data from one shape into another. These specs are just Python objects — dictionaries, lists, and helper constructs — which makes them readable and composable.
This is one of the biggest architectural wins: your transformation logic becomes declarative documentation. This looks as follows in our example
Output
{'request_id': 'req_3524', 'user': {'id': '12', 'full_name': 'Me Not you', 'email': 'menotyou@example.com', 'roles': ['admin', 'billing']}, 'orders': [{'id': 'A100', 'currency': 'AUD', 'total': '19.90', 'coupon': None, 'items': [{'sku': 'BUN-111', 'qty': 2, 'unit_price': '4.95'}, {'sku': 'GOB-222', 'qty': 3, 'unit_price': '10.00'}]}, {'id': 'A101', 'currency': 'AUD', 'total': '0', 'coupon': 'WELCOME', 'items': []}]}First a quick summary what we got
Nested roles flattened into a list of strings
Coupon normalized to always exist
Names combined into a single field
Structural consistency across orders
Note that we still have plenty of wrong types in this output. Let us quickly look at the three commands we used from glom in this little example
T - The Target
Think of T as the target that you are currently processing. Which means you can access it like a dictionary and concatenate values as we did here.
Coalesce
This returns the first successful result, when we search for the entry coupon.code. If it cannot find any, it will return a default value.
glom(...)
This is the main command that applies the specifications to the input payload and returns the structured data.
Typed Models with dataclasses
Dataclasses give you:
explicit schemas
type hints
immutability (optional)
IDE support
predictable behavior
They turn “a dictionary with keys” into a domain object.
We are using frozen dataclasses here to make sure that any created instance is afterwards immutable, this of course depends on your application if you want this or not. We also use typing to give type hints. Note that these are only hints, for example
roles: List[str]suggests that this should be a list of strings, but it does not enforce this. Two excellent tools:
dacite — simple, focused, easy to start
cattrs — flexible, configurable conversion framework
What we now need is a tool to get our data from the dictionary into the dataclass. This is a good point to mention again that you could do this manually, the suggested methods are meant to improve convenience, readability and error finding.
Straightforward dict → dataclass with dacite
Dacite is intentionally minimal. It solves one problem well:
“Given this dictionary, construct this dataclass.”
It works immediately… until types disagree
We could look at an output, but with our specifications in shaped (from the glom example) and types in Envelope (from the dataclasses part) we will end up with an error message anyway, since for example User.id is not provided as an integer.
This is not a bug. The library is telling you:
Your data does not match your schema.
That is exactly what we want at system boundaries. To remedy this we need casting rules
Now int and floats that are required get casted automatically from strings into their respective types.
This can of course still be broken. In the JSON data one could without problem change the quantity entry of order "BUN-111" from "2" to "two". This would create an error message even when using casting.
Conversion Engine with cattrs
cattrs approaches the same problem differently. Instead of “load dict into dataclass,” it provides a conversion engine.
This engine can:
structure data into classes
unstructure classes back into dictionaries
register reusable hooks
centralize conversion logic
That makes it attractive for larger systems or shared libraries. In a basic translation example this looks as follows
Again, our example ends up with an error message. In its basic form it will not convert types.
Instead of a config that told us to convert to int and float as in the dacite example, we have to register explicit converters. The ones we need are of course fairly simple
Which then produces as output
Envelope(request_id='req_3524', user=User(id=42, full_name='Me Not you', email='menotyou@example.com', roles=['admin', 'billing']), orders=[Order(id='A100', currency='AUD', total=19.9, coupon=None, items=[LineItem(sku='BUN-111', qty=2, unit_price=4.95), LineItem(sku='GOB-222', qty=3, unit_price=10.0)]), Order(id='A101', currency='AUD', total=0.0, coupon='WELCOME', items=[])])The key architectural idea here is centralization. Instead of casting in multiple places, you define conversions once. In addition you can also define conversion rules with included logic. For example if you know that a quantity will come in the form '10.9k' you can include a conversion that transforms this to '10900' directly.
When to Reach for Which Tool
A helpful mental model:
JMESPath — exploration and extraction
glom — structural transformation
dacite — simple dataclass loading
cattrs — configurable conversion layer
Or more conceptually:
JMESPath answers: “Where is the data?”
glom answers: “What shape should it be?”
dacite/cattrs answer: “What is it?”
Each layer reduces uncertainty.
The main take aways from this are:
Keep transformation specs centralized. A glom spec can be read similar to a documentation on how you want to have your data be presented.
Prefer loud failures. Silent coercion is technical debt with interest. If the failure comes because data does not match it should not convert. You can always make sure to catch these errors and save the problematic payload somewhere for a manual check or a more in depth logic that tries to figure out what is the problem—remember 'two' vs '2'.
Use JMESPath for debugging. It’s fantastic for inspecting payload changes quickly.
Write a golden payload test. One or two representative JSON samples catch many regressions and type problems.
The Upshot ... or Morale of the Story
This stack works well not because it uses a particular set of libraries, but because it introduces clear separation of concerns. Extraction, reshaping, conversion, and validation each happen in their own step, which makes the overall system easier to reason about and maintain. Instead of mixing data access logic with business logic, you establish a clean boundary where raw, untrusted JSON is transformed into structured, typed objects.
Once you reach something like Envelope(...), the rest of your application can operate with confidence, without constantly checking for missing keys or incorrect types. That shift — from defensive programming to working with trusted data — is where the real value lies.



Comments