PHP password_hash and password_verify Example (Login)
Hash passwords in PHP with password_hash(), check them with password_verify(), and build a PDO register and login that upgrades old hashes. Real output.
To write JSON to a file in Python with pretty printing, open the file in write mode with encoding="utf-8" and call json.dump(data, f, indent=4, ensure_ascii=False). indent=4 puts each key on its own line, indented by four spaces, and ensure_ascii=False keeps characters like £, é and ë readable instead of turning them into \u00a3-style escapes. The json module is built into Python, so there's nothing to install. Below is that example with its real output, plus the questions that come straight after: dates that won't save, overwriting a file safely, and why "appending" to a JSON file breaks it.
This is the companion to my post on reading a CSV file in Python without pandas. CSV is great for tables, but as soon as your data has nested lists, such as a student with several modules, JSON is the better fit. Every snippet was run with Python 3.13 and the outputs are copied from the terminal.
# write_pretty.py: write a dict to a JSON file, pretty-printed
import json
student = {
"name": "Zoë Okafor",
"city": "Manchester",
"modules": [
{"code": "CS101", "mark": 72},
{"code": "CS102", "mark": 64.5},
],
"graduated": False,
"notes": None,
}
with open("student.json", "w", encoding="utf-8") as f:
json.dump(student, f, indent=4, ensure_ascii=False)
f.write("\n") # end the file with a newline, like most editors do
print(open("student.json", encoding="utf-8").read())
Contents of student.json:
{
"name": "Zoë Okafor",
"city": "Manchester",
"modules": [
{
"code": "CS101",
"mark": 72
},
{
"code": "CS102",
"mark": 64.5
}
],
"graduated": false,
"notes": null
}
A few things happen automatically. Python's False becomes JSON's false, None becomes null, and the nested list of dicts is indented properly at each level. Strings and numbers come through unchanged.
The three arguments that matter:
indent=4 turns on pretty printing. Use indent=2 for smaller files, which is what most JavaScript projects use. Without indent, everything goes on one long line.ensure_ascii=False writes Zoë as Zoë. The default (True) would write Zo\u00eb, which is valid JSON but painful to read.encoding="utf-8" on open(). If you use ensure_ascii=False, you need this. Without it, Python on Windows may use an older encoding and either crash on £ or write bytes other programs can't read.json.dump() (no s) writes to a file. json.dumps() (with an s, for "string") returns the JSON as a string instead, which is handy for printing or sending over an API.
Here's what the options do to the same small dict:
import json
data = {"name": "Zoë", "price": "£12.50", "city": "Kraków"}
print(json.dumps(data))
print(json.dumps(data, ensure_ascii=False))
print(json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False))
print(json.dumps(data, separators=(",", ":"), ensure_ascii=False)) # smallest output
Output:
{"name": "Zo\u00eb", "price": "\u00a312.50", "city": "Krak\u00f3w"}
{"name": "Zoë", "price": "£12.50", "city": "Kraków"}
{
"city": "Kraków",
"name": "Zoë",
"price": "£12.50"
}
{"name":"Zoë","price":"£12.50","city":"Kraków"}
The first line is the default, with every non-ASCII character escaped. The second keeps them. sort_keys=True puts the keys in alphabetical order, which I like for config files kept in Git, because the diff stays small when you change one value. The last line uses separators=(",", ":") to remove the spaces after commas and colons, giving the smallest possible output for files no person will read.
Sooner or later you'll hit this error:
import json
from datetime import date, datetime
from decimal import Decimal
order = {"id": 17, "placed": datetime(2026, 10, 5, 14, 30), "total": Decimal("19.99"), "tags": {"gift", "uk"}}
try:
json.dumps(order)
except TypeError as e:
print("TypeError:", e)
def to_json(value):
if isinstance(value, (date, datetime)):
return value.isoformat()
if isinstance(value, Decimal):
return str(value) # keep the exact pennies
if isinstance(value, set):
return sorted(value)
raise TypeError(f"Can't save {type(value).__name__} as JSON")
print(json.dumps(order, indent=2, default=to_json))
Output:
TypeError: Object of type datetime is not JSON serializable
{
"id": 17,
"placed": "2026-10-05T14:30:00",
"total": "19.99",
"tags": [
"gift",
"uk"
]
}
JSON only knows strings, numbers, booleans, null, lists and objects. When json.dumps() meets anything else it calls the function you pass as default= and saves whatever that function returns. Here, datetimes become ISO 8601 strings (2026-10-05T14:30:00), which any language can read back. Decimal becomes a string so the exact value survives (turning it into a float would bring back the rounding errors you used Decimal to avoid), and sets become sorted lists. Anything else still raises a clear TypeError, which is better than silently saving rubbish.
When you read the file back, those values are plain strings. Convert them with datetime.fromisoformat() and Decimal() where you need them.
The most common real job is: read a JSON file, change a value, write it back. Opening with "w" empties the file straight away, so if your program crashes halfway through json.dump(), you're left with half a file and lost data. Writing to a temporary file first and then swapping it in avoids that:
# update.py: read, change and overwrite a JSON file safely
import json
import os
import tempfile
from pathlib import Path
def load_json(path, default):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return default
def save_json(path, data):
"""Write to a temp file first, then swap it in, so a crash can't leave half a file."""
path = Path(path)
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
os.replace(tmp, path)
settings = load_json("settings.json", {"theme": "light", "visits": 0})
settings["visits"] += 1
settings["theme"] = "dark"
save_json("settings.json", settings)
print(Path("settings.json").read_text(encoding="utf-8"))
Output after running it twice:
{
"theme": "dark",
"visits": 1
}
{
"theme": "dark",
"visits": 2
}
os.replace() swaps the new file in with a single rename, which is atomic on the same drive: other programs see either the old file or the complete new one, never a half-written mix. The temp file is made in the same folder for exactly that reason. load_json() also returns a default when the file doesn't exist yet, so the first run doesn't crash. It's a few more lines than open("w"), but I use it for anything I'd be upset to lose, such as settings or a small app's saved data. My offline habit tracker with localStorage does the same read-change-save cycle in the browser, using JSON.stringify instead.
Opening a JSON file in append mode ("a") and calling json.dump() again looks reasonable, but it breaks the file:
# append.py: "appending" to JSON. Don't open the file with "a"
import json
from pathlib import Path
# Wrong: two dumps in append mode make a file json.load() can't read
Path("broken.json").unlink(missing_ok=True)
for item in [{"n": 1}, {"n": 2}]:
with open("broken.json", "a", encoding="utf-8") as f:
json.dump(item, f)
try:
json.load(open("broken.json", encoding="utf-8"))
except json.JSONDecodeError as e:
print("JSONDecodeError:", e)
# Option 1: JSON Lines, one object per line, safe to append
Path("log.jsonl").unlink(missing_ok=True)
for item in [{"n": 1}, {"n": 2}, {"n": 3}]:
with open("log.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(item) + "\n")
with open("log.jsonl", encoding="utf-8") as f:
print([json.loads(line) for line in f])
# Option 2: read the list, append in Python, write the whole file back
Path("list.json").write_text("[]", encoding="utf-8")
items = json.loads(Path("list.json").read_text(encoding="utf-8"))
items.append({"n": 4})
Path("list.json").write_text(json.dumps(items, indent=2), encoding="utf-8")
print(Path("list.json").read_text(encoding="utf-8"))
Output:
JSONDecodeError: Extra data: line 1 column 9 (char 8)
[{'n': 1}, {'n': 2}, {'n': 3}]
[
{
"n": 4
}
]
A JSON file holds exactly one value. Two dumps in a row give {"n": 1}{"n": 2}, and json.load() fails with "Extra data". You have two good options:
.jsonl): write one JSON object per line, with no indent. Appending is safe, and you read it back line by line. It's ideal for logs and anything that grows over time..append() in Python, and write the whole file back (ideally with save_json() from above). Fine for small files.The list comprehension that reads the JSON Lines file back is explained in my post on Python list comprehension examples.
If you already have a squashed JSON file, you don't need to write any code. Python includes a command-line formatter:
$ python -m json.tool --indent 2 ugly.json
{
"b": 2,
"a": [
1,
2,
3
]
}
Add --sort-keys to sort the keys too, or redirect the output to a new file with > pretty.json. Don't redirect it into the same file you're reading, because the shell empties that file before Python reads it.
import json, then with open("data.json", "w", encoding="utf-8") as f: json.dump(my_dict, f, indent=4). That creates or overwrites data.json with your dict, pretty-printed.
That's the default ensure_ascii=True. Pass ensure_ascii=False to json.dump() and open the file with encoding="utf-8".
json.dump(obj, f) writes JSON to an open file. json.dumps(obj) returns it as a string. Both take the same indent, sort_keys and ensure_ascii options.
Pass a default= function that converts datetimes to strings with .isoformat(), as in the to_json() example above. For a quick fix, default=str also works.
Yes, with mode "w" the file is emptied and replaced. Use mode "a" only for JSON Lines files, and use the temp file and os.replace() pattern when losing the old file would hurt.
If you'd like to format values before saving them, my post on Python f-strings covers numbers, dates and padding. The json module documentation lists every option.
// note
This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.
If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.
Hash passwords in PHP with password_hash(), check them with password_verify(), and build a PDO register and login that upgrades old hashes. Real output.
Stop tracking a file in Git without deleting it: git rm --cached, .gitignore, whole folders, a dry run, undoing it, and the catch for teammates and secrets.
Two UK postcode regex patterns for JavaScript tested on 14 inputs, a function that tidies 'sw1a1aa' into 'SW1A 1AA', and a form that uses it.