TL;DR
- Python JSON is the built-in module that turns JSON strings and files into Python objects, and back again.
- You'll get working code for reading, writing, and pretty-printing JSON, plus the type conversion rules Python follows.
- Custom objects need extra steps to serialize and decode – this guide walks through both directions.
- Learn what to do when the JSON you need isn't sitting in a file, but locked behind JavaScript or a login instead.
Introduction
You usually encounter Python JSON at the moment you touch an API, a config file, or a coding interview question, and it rarely behaves exactly the way you expect. You ask a service for data and get back a wall of text, or you build a dictionary in your script and need it saved in a format another program can read.
Below, you'll find what JSON actually is, how Python's built-in JSON module handles it in both directions, and what changes once custom objects enter the picture.
We'll also cover what happens when the JSON is buried behind JavaScript rendering or a login wall instead of sitting in a file.
What is JSON in Python?
JSON, short for JavaScript Object Notation, is a lightweight data interchange format built on plain text, made up of key value pairs, nested objects, and arrays.
It reads like a stripped-down version of a Python dictionary, a resemblance that isn't an accident: JSON's structure maps almost directly onto Python's own data types, the same way it maps onto JavaScript objects in a browser.
Python has no separate "JSON type."
A JSON object becomes a Python dictionary, a JSON array becomes a list, and a JSON string, number, or boolean becomes the Python equivalent of each.
The json module exists to make that conversion automatic in both directions, with the focus being getting JSON and Python to talk to each other.
It's important to make a clear distinction between three terms you'll come across at this point:
A JSON string is just text, formatted according to the JSON specification.
A JSON file is that same text saved to disk.
A Python object, like a dictionary you've built in a script, is neither of those until you convert it.
Nearly every method in the JSON module exists to move data between one of these forms and another.
JSON earned its place as the default way to exchange data between systems because it's genuinely lightweight next to the alternatives.
A configuration file and an API response can both use the same handful of JavaScript Object Notation rules that a message passed between two services relies on: objects in braces, arrays in brackets, values separated by commas.
Python didn't invent any of that. It just gives you a module that speaks the same language your browser and half the tools on your machine already speak.
The Python JSON module
Getting started with JSON in Python takes one line: import json.
It's part of the standard library, so there's no package to install and no version to manage. That single import gives you everything covered in this guide.
The module splits cleanly into two jobs:
Reading JSON, whether from a string or a file, and converting JSON objects and arrays into Python dictionaries and lists, is handled by loads() and load().
Writing a Python object back out as JSON, again either as a string or a file, is what people usually mean by json dumps: that job is handled by dumps() and dump().
A second pair of tools, the default value and object_hook, handles the trickier case of custom objects that don't map onto a plain dictionary or list.
A third-party library called simplejson offers a largely compatible interface to the same problem, and some older codebases still use it for extra features or slightly faster performance. Unless you already have a reason to use it, however, the built-in module covers what nearly everyone needs.
Using the JSON module from the command line
When you want to check a file without writing a script for it, the JSON module doubles as a command line tool. Running a file through it from the terminal validates and reformats it in one step:
python -m json.tool settings.json
If settings.json holds valid JSON, this prints it back out pretty-printed, with consistent indentation. If it doesn't, the Python interpreter reports the exact parsing error instead, including where in the file it gave up. Pass a second filename to write the reformatted version to a new file instead:
python -m json.tool settings.json formatted-settings.json
That process is often faster than opening a script for a one-off check, and it uses the exact same parser as json.load() behind the scenes, so a file that passes here will parse cleanly in your code too.
How do you process JSON in Python?
At a high level, working with JSON in Python follows the same three steps regardless of where the data comes from.
First, parse the JSON into a Python object using loads() or load().
Second, work with that object using ordinary dictionary and list operations: reading values, updating them, looping through nested objects.
Third, convert it back to JSON with dumps() or dump() once you're ready to save or send it somewhere.
Here's that round trip in miniature, updating one field in a small JSON document:
import json
json_string = '{"name": "Ada", "role": "engineer", "active": false}'
data = json.loads(json_string)
data["active"] = True
updated_json = json.dumps(data)
print(updated_json)
Run this, and you'll get back {"name": "Ada", "role": "engineer", "active": true}, the same structure with one value changed.
Everything else in this guide is a closer look at one part of that cycle: parsing in more detail, reading and writing files, and handling data that doesn't fit neatly into a dictionary.
How to parse JSON in Python
Parsing a JSON string into a Python object is what json.loads() does; the name is short for "load string."
Here's a working example against a slightly more realistic piece of data:
import json
json_string = '''
{
"name": "Ada",
"age": 36,
"skills": ["python", "json"],
"active": true
}
'''
data = json.loads(json_string)
print(data["name"])
print(data["skills"][1])
print(data["age"] + 1)
That prints Ada, json, and 37. Once parsed, data is a plain Python dictionary, so accessing nested objects and JSON array elements works exactly the way it would with any dictionary or list you built by hand.
Real JSON documents usually go a level deeper than a flat dictionary, with objects nested inside other objects or lists full of them. Parsing doesn't change; only the path you walk to reach a value does:
json_string = '{"team": "Engineering", "members": [{"name": "Ada"}, {"name": "Grace"}]}'
data = json.loads(json_string)
for member in data["members"]:
print(member["name"])
That loops through members as a list of dictionaries and prints each name – the same pattern you'd use whether the list has two entries or two thousand.
Python follows a fixed conversion table when it parses JSON:
| JSON | Python |
|---|---|
| object | dict |
| array | list |
| string | str |
| number (integer) | int |
| number (real) | float |
| true / false | True / False |
| null | None |
Parsing fails on invalid JSON: a trailing comma, single quotes instead of double, or an unquoted key will all raise a json.JSONDecodeError rather than silently returning something close enough.
Bad data should fail loudly instead of producing a Python object that looks right, but isn't.
JSON int, float, and other value types
Python's int and float types map onto JSON's number type, though it's worth knowing that JSON itself makes no distinction between them the way Python does.
A JSON number becomes an int if it has no decimal point and a float if it does. Very large integers parse correctly in Python, since Python integers aren't limited to a fixed bit size, but floats can lose precision on round-tripping in the same way they do anywhere else in Python.
If exact precision matters, such as with currency values, convert those fields to Decimal after parsing rather than trusting the raw float.
How to handle invalid JSON and JSON errors
Working with JSON data that's come from outside your own code means occasionally getting handed something that isn't valid JSON.
A few common culprits are:
Hand-edited config – someone leaves a trailing comma after the last value in an object or array.
An unquoted key – valid in a plain Python dictionary literal, invalid in JSON.
An unexpected response body – a login page or error message returned instead of the data you asked for.
Python's json.JSONDecodeError carries more detail than a generic exception, and it's worth reading rather than just catching and discarding.
import json
bad_json = '{"name": "Ada", "age": 36,}'
try:
json.loads(bad_json)
except json.JSONDecodeError as error:
print(f"Message: {error.msg}")
print(f"Line: {error.lineno}, column: {error.colno}")
The msg, lineno, and colno attributes point at exactly where parsing gave up, turning a vague failure into an exact line and column you can go check.
Invalid JSON numbers can trip a parse up too, but they don't all behave the same way. A leading zero (like 01) raises json.JSONDecodeError, since the spec disallows it.
NaN and Infinity aren't valid JSON either, but Python's json module accepts them by default in both directions – pass allow_nan=False to json.dumps() when you need output a stricter parser will accept.
If you're validating JSON that comes from users or an external system rather than debugging your own code, wrap the parse in a function that returns a clear true or false instead of letting the exception surface:
def is_valid_json(text):
try:
json.loads(text)
return True
except json.JSONDecodeError:
return False
That pattern comes up often enough, in form validation, config loading, and API request handling, that it's worth keeping as a small utility rather than rewriting the try/except block every time.
How to read a JSON file in Python
Reading a JSON file works the same way as parsing a string, using json.load() instead of json.loads(), note the missing "s". The difference is the argument: load() expects a file object, not a string.
import json
with open("settings.json") as f:
settings = json.load(f)
print(settings["theme"])
The with open(...) pattern closes the file automatically once the block finishes, even if something goes wrong while reading it. It's the standard way to read a JSON file in Python, and it applies to writing files too, covered in the next section.
Sometimes you have JSON text in memory, rather than on disk, but need to hand it to a function that expects a file-like object. That's what StringIO is for:
from io import StringIO
import json
json_stream = StringIO('{"status": "ok", "code": 200}')
result = json.load(json_stream)
print(result["code"])
Two errors show up often enough to handle explicitly: the file might not exist, and its contents might not be valid JSON.
import json
try:
with open("settings.json") as f:
settings = json.load(f)
except FileNotFoundError:
print("settings.json is missing.")
except json.JSONDecodeError as error:
print(f"settings.json isn't valid JSON: {error}")
Catching these separately means you can tell a missing file apart from a corrupted one, which is useful when you're deciding how to recover.
How to convert Python objects to JSON
Converting a Python object into JSON uses json.dumps() for a string, or json.dump() to write straight to a file:
import json
user = {"name": "Grace", "role": "engineer", "active": True}
json_string = json.dumps(user)
print(json_string)
with open("user.json", "w") as f:
json.dump(user, f)
The conversion runs the earlier table in reverse: a dict becomes an object, a list becomes an array, and Python's True, False, and None become true, false, and null.
Raw dumps() output is compact and hard to read. This indent parameter fixes that:
print(json.dumps(user, indent=2, sort_keys=True))
That parameter produces a multi-line, indented block with keys in alphabetical order – useful when you need to pretty print JSON data for debugging or for a configuration file a human might open later.
Going the other direction, a compact JSON representation with no extra whitespace is smaller to store or transmit:
json.dumps(user, separators=(",", ":"))
Passing tighter separators strips the default spacing between items and after colons, shaving a meaningful number of bytes off a large payload.
Not every Python value is JSON-serializable by default. Dictionaries, lists, tuples, strings, integers, floats, booleans, and None all convert without any extra work. Anything else, whether that's a datetime, a set, an instance of your own class, or some other custom type, needs the same kind of help covered in the next section.
Serializing custom objects to JSON
Everything so far has worked because dictionaries, lists, strings, and numbers are all things json.dumps() already understands. A custom object isn't. Try to serialize an instance of your own class, and the encoder only recognizes basic Python object hierarchies out of the box.
import json
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
ada = Person("Ada Lovelace", 36)
json.dumps(ada)
That raises TypeError: Object of type Person is not JSON serializable. dumps() has no idea how to turn a Person into JSON on its own, so the fix is a default function that tells it how:
import json
def person_to_dict(obj):
if isinstance(obj, Person):
return {"name": obj.name, "age": obj.age}
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
ada = Person("Ada Lovelace", 36)
print(json.dumps(ada, default=person_to_dict))
dumps() calls person_to_dict whenever it hits something it can't serialize directly, and the function hands back a plain dictionary it does know how to handle.
If the same custom serialization needs to happen in more than one place across a codebase, a JSONEncoder subclass keeps that logic in one spot instead of passing a default function around everywhere:
class PersonEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Person):
return {"name": obj.name, "age": obj.age}
return super().default(obj)
print(json.dumps(ada, cls=PersonEncoder))
The cls parameter tells dumps() to use PersonEncoder instead of the default one, and the default method inside it does the same job as the standalone function above.
Deserializing JSON into custom objects
Decoding works the same way in reverse. Parse a JSON string with json.loads(), and you get a plain dictionary back, not an instance of your original class:
import json
json_string = '{"name": "Ada Lovelace", "age": 36}'
data = json.loads(json_string)
print(type(data))
That prints <class 'dict'>, not <class 'Person'>. To decode JSON strings straight back into a custom object, pass an object_hook function to loads():
def dict_to_person(data):
if "name" in data and "age" in data:
return Person(data["name"], data["age"])
return data
ada = json.loads(json_string, object_hook=dict_to_person)
print(ada.name, ada.age)
object_hook runs on every dictionary the decoder produces, checking whether it looks like a Person and building one if so, or returning the dictionary unchanged if not.
That check is important once your JSON has more than one shape of object in it. For a single, flat class like this one, object_hook is enough.
Nested custom objects, with instances inside other instances, need a more deliberate decoding strategy – usually a custom object_hook that checks for a type marker in the data rather than guessing from field names.
How to scrape JSON data in Python
Most of the JSON you'll work with doesn't start as a file. It starts as an API response, and the standard approach is a couple of lines:
import requests
response = requests.get("https://api.example.com/products")
products = response.json()
print(products[0]["name"])
That works fine for a public, unauthenticated endpoint that returns JSON directly. It breaks down in a few common situations:
The API only exists after JavaScript runs. A plain HTTP request gets back an HTML shell instead of data, and response.json() fails immediately.
The endpoint sits behind a login. An anonymous request never reaches the data at all.
The site runs bot detection. A request that doesn't look like it came from a real browser gets blocked or redirected before it ever sees JSON.
None of these are problems with the JSON module. The parsing code you've already seen works fine once it gets valid JSON to parse. The actual blocker is retrieving the data in the first place, which is a browser automation problem, not a Python one.
A headless browser solves the first problem by rendering the page the way a browser would before you try to read anything from it. Browserless runs that browser for you, so you don't need to manage one locally.
Its /smart-scrape REST endpoint is built for exactly this: point it at a URL, and if the target returns JSON, you'll find it in the response's content field – as a parsed object for a JSON object, or as a string you pass straight back through json.loads() for a JSON array.
import json
import requests
TOKEN = "YOUR_API_TOKEN_HERE"
url = f"https://production-sfo.browserless.io/smart-scrape?token={TOKEN}"
payload = {"url": "https://jsonplaceholder.typicode.com/users"}
response = requests.post(url, json=payload)
content = response.json()["content"]
products = json.loads(content) if isinstance(content, str) else content
print(products[0]["name"])
/smart-scrape escalates automatically: it tries a fast HTTP fetch first, retries the request through a proxy if that's blocked, and only then spins up a full headless browser with bot-detection and CAPTCHA handling.
For sites that fight back harder, with CAPTCHAs or stricter fingerprinting, /unblock picks up where that escalation leaves off, returning the page content alongside cookies or a live browser connection you can keep automating against.
For a case where you need the JSON a page requests internally, not the page itself, BrowserQL can intercept that network call directly with its response mutation.
Our data extraction guide covers this approach alongside mapping DOM elements straight into structured JSON, if you'd rather not work with raw response bodies:
import json
import requests
TOKEN = "YOUR_API_TOKEN_HERE"
url = f"https://production-sfo.browserless.io/chromium/bql?token={TOKEN}"
query = """
mutation GetProductData {
goto(url: "https://example.com/products", waitUntil: load) {
status
}
response(type: xhr, method: GET, url: ["*/api/products*"], operator: and) {
url
body
}
}
"""
result = requests.post(url, json={"query": query}).json()
raw_body = result["data"]["response"][0]["body"]
products = json.loads(raw_body)
That query loads the page, waits for the matching XHR call the page itself makes, and hands back the raw response body, which you parse with the same json.loads() covered earlier in this guide.
Browserless gets you to the data; the json module still does the actual parsing, exactly as it would for a file on disk.
If your pipeline already leans on a Python scraping library rather than raw requests calls, Browserless documents drop-in patterns for pointing BeautifulSoup or Scrapy at a rendered page instead of a local browser.
For extraction logic more custom than any single endpoint covers, the /function endpoint runs your own Puppeteer script server-side and hands back whatever JSON structure you build inside it.
Conclusion
Working with JSON in Python is essentially using a small number of tools consistently: loads() and load() to bring data in, dumps() and dump() to send it back out, and default or object_hook for the custom objects that don't fit either direction cleanly.
Once you've got those down, the only thing left to solve is getting the JSON in the first place, and that's where a JavaScript-rendered API or a bot-detection wall can stop a plain requests call cold.
If that's the part slowing you down, sign up for Browserless and point /smart-scrape at the endpoint that's been giving you trouble, so your json module gets the clean data it was built for.
The same approach carries over to Browserless's broader web scraping tooling any time the data you're after isn't JSON at all.
Python JSON FAQs
Is JSON the same as a Python dictionary?
Not quite. JSON is a text format, while a Python dictionary is an in-memory data structure. json.loads() converts one into the other, but until that conversion happens, a JSON object and a Python dictionary aren't interchangeable, even though they look similar on the page.
How do you check if a string is valid JSON?
Try to parse it with json.loads() inside a try/except block. If it raises json.JSONDecodeError, the string isn't valid JSON. The standard library has no dedicated validation function for this, so attempting the parse and catching the error is the standard approach.
What's the difference between json.load() and json.loads()?
json.load() reads from a file object, while json.loads() reads from a string already in memory. The "s" in loads() stands for string. Mixing them up is one of the most common JSON-Python mistakes: passing a string to load() or a file object to loads() raises a TypeError rather than working either way.
Can Python parse JSON with comments or trailing commas?
No. The JSON specification doesn't allow comments or trailing commas, and Python's json module enforces that strictly, raising json.JSONDecodeError on either one.
If you need a config format that supports comments, look at a format built for it, like TOML or YAML, rather than trying to bend JSON to fit.