Files, JSON, APIs & Exception Handling — With Runnable Examples
This week you cross a major milestone: your programs start talking to the outside world. Files let you save data between runs. JSON is the format almost every API uses. HTTP requests let Python fetch live data from the internet.
.py file and run it. The
file I/O and JSON examples work immediately. The HTTP example requires
pip install requests first.
A dictionary stores data as named pairs: a key and its value. Instead of accessing data by position (like a list), you access it by name.
person = {
"name": "Alice",
"age": 28,
"city": "Austin",
"active": True
}
print(person["name"]) # Alice
print(person["age"]) # 28
# .get() returns None (or a default) instead of crashing if key is missing
print(person.get("email")) # None
print(person.get("email", "N/A")) # N/A
profile = {"username": "bob42", "score": 100}
profile["email"] = "bob@example.com" # add new key
profile["score"] = 150 # update existing key
profile.pop("email") # remove a key
print(profile) # {'username': 'bob42', 'score': 150}
config = {"host": "localhost", "port": 8080, "debug": True}
# Loop over keys
for key in config:
print(key)
# Loop over key-value pairs (most common)
for key, value in config.items():
print(f" {key}: {value}")
# Just values
for value in config.values():
print(value)
API responses are often dictionaries inside dictionaries. Practice navigating them:
user = {
"name": "Carol",
"address": {
"city": "Dallas",
"state": "TX",
"zip": "75201"
},
"scores": [88, 92, 79]
}
print(user["address"]["city"]) # Dallas
print(user["address"]["state"]) # TX
print(user["scores"][0]) # 88 (first score)
Files let your program persist data — the information survives after the program
closes, and is available the next time it runs. Python's built-in open() function handles
both reading and writing.
with open(...) as f: pattern. It automatically
closes the file when the block ends, even if an error occurs.
# "w" mode — write (creates the file, or overwrites it if it exists)
with open("notes.txt", "w") as f:
f.write("First line\n")
f.write("Second line\n")
# "a" mode — append (adds to the end without erasing existing content)
with open("notes.txt", "a") as f:
f.write("Third line\n")
# Read the entire file as one string
with open("notes.txt", "r") as f:
contents = f.read()
print(contents)
# Read line by line — memory-efficient for large files
with open("notes.txt", "r") as f:
for line in f:
print(line.strip()) # .strip() removes the trailing newline
# Read all lines into a list
with open("notes.txt", "r") as f:
lines = f.readlines()
print(lines) # ['First line\n', 'Second line\n', 'Third line\n']
try:
with open("settings.txt", "r") as f:
data = f.read()
except FileNotFoundError:
print("File not found — using defaults.")
data = ""
Exceptions are errors that happen while your program runs. Without handling them, a single bad input or
network hiccup crashes your entire program. try/except lets you catch specific errors and
recover gracefully.
# ValueError — wrong type of value (e.g. int("hello"))
try:
number = int(input("Enter a number: "))
print(f"Double: {number * 2}")
except ValueError:
print("That wasn't a valid number.")
# ZeroDivisionError — dividing by zero
try:
result = 100 / int(input("Divisor: "))
print(f"Result: {result}")
except ZeroDivisionError:
print("Can't divide by zero.")
except ValueError:
print("That wasn't a number.")
try:
value = int(input("Enter a positive number: "))
if value <= 0:
raise ValueError("Must be positive")
result = 100 / value
except ValueError as e:
print(f"Input error: {e}")
else:
# Runs only if NO exception was raised
print(f"100 / {value} = {result:.2f}")
finally:
# ALWAYS runs — great for cleanup
print("Done.")
def set_age(age):
if not isinstance(age, int):
raise TypeError("Age must be an integer")
if age < 0 or age > 150:
raise ValueError(f"Age {age} is not realistic")
return age
try:
set_age(-5)
except ValueError as e:
print(f"Error: {e}") # Error: Age -5 is not realistic
JSON (JavaScript Object Notation) is a text format for storing structured data. It looks almost identical to Python dictionaries and lists — and that's exactly what Python converts it to and from.
json.dumps() converts a Python object to a JSON
string. json.loads() converts a JSON string back to a Python object.
json.dump() / json.load() do the same directly to/from files.
import json
# Python dict → JSON string
data = {"name": "Alice", "score": 95, "tags": ["python", "ai"]}
json_string = json.dumps(data, indent=2)
print(json_string)
# {
# "name": "Alice",
# "score": 95,
# "tags": ["python", "ai"]
# }
# JSON string → Python dict
back = json.loads(json_string)
print(back["name"]) # Alice
print(type(back)) #
import json
# Save to file
favorites = ["Austin", "Dallas", "Seattle"]
with open("favorites.json", "w") as f:
json.dump(favorites, f, indent=2)
# Load from file
with open("favorites.json", "r") as f:
loaded = json.load(f)
print(loaded) # ['Austin', 'Dallas', 'Seattle']
print(type(loaded)) #
A very common real-world pattern — load saved data if it exists, start fresh if it doesn't:
import json
def load_history(filename):
try:
with open(filename, "r") as f:
return json.load(f)
except FileNotFoundError:
return [] # first run — no file yet, start with empty list
def save_history(filename, history):
with open(filename, "w") as f:
json.dump(history, f, indent=2)
history = load_history("search_history.json")
history.append({"query": "python tutorials", "timestamp": "2026-03-10"})
save_history("search_history.json", history)
API keys are passwords — they should never be written directly in your code (which could be accidentally
shared or published). Instead, store them as environment variables and read them at
runtime with os.getenv().
import os
# Read an environment variable
api_key = os.getenv("MY_API_KEY")
# Provide a default for optional variables
debug_mode = os.getenv("DEBUG", "false")
# Fail fast if a required variable is missing
api_key = os.getenv("MY_API_KEY")
if api_key is None:
print("Error: MY_API_KEY environment variable is not set.")
print("Set it with: export MY_API_KEY=your_key_here")
exit(1)
print(f"Key loaded: {api_key[:4]}...") # show first 4 chars only, never full key
# Mac / Linux
export MY_API_KEY=abc123xyz
# Windows Command Prompt
set MY_API_KEY=abc123xyz
# Windows PowerShell
$env:MY_API_KEY="abc123xyz"
# Both: verify it's set
echo $MY_API_KEY # Mac/Linux
echo %MY_API_KEY% # Windows CMD
The requests library lets Python send HTTP requests to web APIs and receive data back,
just like a browser does when you visit a URL.
pip install requests in your terminal before using
this module.
import requests
# GET request — fetch data from a URL
response = requests.get("https://api.github.com")
print(response.status_code) # 200 = OK
print(response.headers["Content-Type"])
# Parse the JSON response body into a Python dict
data = response.json()
print(data["current_user_url"])
import requests
# Pass parameters as a dict — requests builds the URL automatically
# This becomes: https://api.example.com/search?q=python&limit=5
params = {
"q": "python",
"limit": 5,
}
response = requests.get("https://api.example.com/search", params=params)
print(response.url) # shows the full URL that was sent
print(response.status_code)
import json
import requests
def fetch_data(url, params=None):
try:
response = requests.get(url, params=params, timeout=5)
response.raise_for_status() # raises HTTPError for 4xx/5xx responses
return response.json()
except requests.exceptions.ConnectionError:
print("Error: Could not connect. Check your internet connection.")
except requests.exceptions.Timeout:
print("Error: The request timed out.")
except requests.exceptions.HTTPError as e:
print(f"Error: Server returned {e.response.status_code}")
except json.JSONDecodeError:
print("Error: Response was not valid JSON.")
return None
data = fetch_data("https://api.github.com")
if data:
print("Connected successfully!")
API responses are just nested Python dicts and lists once parsed. Here's how to dig into them:
# Imagine an API returned this response (stored here as a Python dict)
response_data = {
"city": "Austin",
"main": {
"temp": 72.4,
"humidity": 58,
"feels_like": 71.0
},
"weather": [
{"id": 800, "description": "clear sky"}
],
"wind": {
"speed": 8.3
}
}
# Navigate the structure
city = response_data["city"]
temp = response_data["main"]["temp"]
humidity = response_data["main"]["humidity"]
description = response_data["weather"][0]["description"] # "weather" is a list
wind = response_data["wind"]["speed"]
print(f"{city}: {temp}°F, {description}")
print(f"Humidity: {humidity}%, Wind: {wind} mph")
# Use .get() for optional fields — returns None instead of crashing
feels_like = response_data["main"].get("feels_like")
if feels_like:
print(f"Feels like: {feels_like}°F")
Most real programs have a menu that keeps running until the user quits. This pattern combines a
while True loop, input validation, and try/except cleanly.
def show_menu():
print("\n--- Note Saver ---")
print("1. Add a note")
print("2. View all notes")
print("3. Clear notes")
print("4. Quit")
def main():
notes = []
while True:
show_menu()
choice = input("Choose an option: ").strip()
try:
option = int(choice)
except ValueError:
print("Please enter a number.")
continue
if option == 1:
note = input("Enter your note: ").strip()
if note:
notes.append(note)
print(f"Saved! ({len(notes)} total notes)")
else:
print("Note can't be empty.")
elif option == 2:
if notes:
for i, note in enumerate(notes, 1):
print(f" {i}. {note}")
else:
print("No notes yet.")
elif option == 3:
notes.clear()
print("All notes cleared.")
elif option == 4:
print("Goodbye!")
break
else:
print("Invalid option. Choose 1–4.")
main()
A mini contact book that combines files, JSON, exception handling, and a menu loop. No external APIs needed — run this immediately.
import json
import os
FILENAME = "contacts.json"
def load_contacts():
try:
with open(FILENAME, "r") as f:
return json.load(f)
except FileNotFoundError:
return {}
def save_contacts(contacts):
with open(FILENAME, "w") as f:
json.dump(contacts, f, indent=2)
def add_contact(contacts):
name = input("Name: ").strip()
phone = input("Phone: ").strip()
if name and phone:
contacts[name] = phone
save_contacts(contacts)
print(f"Saved {name}.")
else:
print("Name and phone can't be empty.")
def find_contact(contacts):
name = input("Search name: ").strip()
if name in contacts:
print(f" {name}: {contacts[name]}")
else:
print("Contact not found.")
def list_contacts(contacts):
if contacts:
for name, phone in sorted(contacts.items()):
print(f" {name}: {phone}")
else:
print("No contacts saved.")
def main():
contacts = load_contacts()
print(f"Loaded {len(contacts)} contact(s).")
while True:
print("\n1. Add 2. Find 3. List 4. Quit")
choice = input("> ").strip()
if choice == "1":
add_contact(contacts)
elif choice == "2":
find_contact(contacts)
elif choice == "3":
list_contacts(contacts)
elif choice == "4":
break
else:
print("Enter 1, 2, 3, or 4.")
main()
load_contacts() and save_contacts() each do exactly
one job. The menu loop never touches the file directly — it always goes through those functions. When
you need to change the file format, you change it in one place.