Skip to content

Files and exceptions

docs.scrimba.com

Most programs that do real work touch the filesystem: reading a config, writing results, loading data. And when things go wrong, Python raises an exception, a signal that something unexpected happened. This chapter covers both: getting data in and out of files, and writing code that handles errors gracefully instead of crashing.

File I/O and exception handling are the two mechanisms that make a program reliable. open() gives access to the filesystem; with makes sure the file is cleaned up even on error. try/except catches specific exception types so you can recover instead of crashing. Together they are the foundation of any script that runs unattended.

Files and exceptions are where a script meets the messy outside world: a path that does not exist, a disk that fills up, input that does not parse. open() hands back a file object, and with guarantees it gets closed even when the code inside raises. Exceptions are how Python signals that failure: when one is raised, it travels up through the calls that led there, and the first matching except along the way handles it. The discipline this chapter is about is catching the specific failures you can actually recover from and letting the rest surface loudly, rather than swallowing everything and shipping a script that fails in silence.

Opening files

open() opens a file and returns an object you can read from or write to. You tell it the path and what you want to do with the file (read, write, or append). Always close a file when you are done; the with statement does this automatically.

open(path, mode) returns a file object. The mode string controls access: "r" reads, "w" writes (clearing the file first), "a" appends to the end. Add "b" for binary mode when the file is not text. One thing worth setting from day one: pass encoding="utf-8" for text files. Leave it off and Python uses whatever the machine's default is, so a file that reads fine on your laptop can come back with broken characters on someone else's.

open() asks the OS for a handle on the file and returns a file object wrapped around it. The mode picks both the access ("r", "w", "a") and whether you get text or raw bytes ("b"). The parameter that bites people is encoding: it decides how bytes on disk map to characters. Leave it unset and Python falls back to the machine's locale default, which differs between platforms, so a file written on one machine can decode wrong on another. Always pass encoding="utf-8" for text unless you have a specific reason not to, and the surprises mostly disappear.

python
f = open("data.txt", "r")    # "r" = read
content = f.read()
f.close()

The "r" is the mode:

ModeMeaning
"r"Read. File must exist. Default mode.
"w"Write. Creates or overwrites the file.
"a"Append. Adds to the end without erasing.
"x"Create. Fails if the file already exists.
"r+"Read and write.
"b"Binary. Add to any mode: "rb", "wb".

Always call .close() when you are done. Forgetting it leaves the file open and can lose data that was still waiting to be written. The reliable way to handle this is the with statement.

JunoOpening filesopen(path, mode) hands you a file object, and the mode says what you want to do: "r" to read, "w" to write (which wipes whatever was there), "a" to add to the end. I lost a file to "w" once by reaching for it when I meant "a", so picture the mode before you type it.
JunoOpening filesopen(path, mode) returns a file object: "r" read, "w" overwrite, "a" append, plus "b" for binary. Pass encoding="utf-8" on text files so they read the same on every machine, and let with handle the closing.
JunoOpening filesopen() gives you a file object over an OS handle; the mode sets access and text-versus-bytes. The setting that travels badly is encoding: leave it off and you inherit the machine's locale default, so make encoding="utf-8" the habit for text. Never lean on .close() by hand when with will do it for you.

The with statement

with open(...) manages the file for you, closing it automatically when the indented block finishes, even if an error happens. Always use with open(...) instead of manual open()/close(). It is safer and it is the standard.

with is Python's context manager syntax: a small protocol where an object gets a "set up" call when the block starts and a "tear down" call when it ends. For a file, set-up returns the open file and tear-down closes it. The win is that the tear-down runs even if an exception is raised inside the block, so you get the cleanup of a try/finally without writing one around every file.

A context manager (any object you can put after with) defines two pieces of behaviour: what to do on the way in, and what to do on the way out. with open(...) as f runs the way-in step, binds the file to f, and registers the way-out step, which Python runs when the block ends, whether it ended normally or because an exception was raised. For files the way-out step is close(). Two practical notes. You can open several files in one statement, with open(a) as f, open(b) as g:, and both close in order. And the way-out step can choose to swallow the exception, which is how helpers like contextlib.suppress(FileNotFoundError) work, so know that a with block can quietly absorb an error if the context manager was written to.

python
with open("data.txt", "r") as f:
    content = f.read()

# f is closed here, guaranteed

with is Python's context manager syntax: it runs setup and teardown code for you, here opening the file and reliably closing it. You do not need to know how it works internally to use it with open().

JunoThe with statementwith open(...) as f opens the file, lets you work with f inside the indented block, then closes it for you when the block ends, even if something goes wrong partway through. Reach for it every time instead of calling .close() yourself, it is one less thing to forget.
JunoThe with statementwith runs setup when the block opens and cleanup when it closes, and the cleanup fires even on an exception. For a file that cleanup is close(), so you get try/finally safety without writing one. Prefer it over manual open()/close() every time.
JunoThe with statement A context manager closes the file on the way out whether the block finished or raised, so with replaces hand-written try/finally for cleanup. Open several at once with one with. Keep in mind the way-out step can suppress an exception, which is exactly what contextlib.suppress is for and worth recognising when a with block swallows an error.

Reading files

Three methods for reading. .read() loads the entire file as one string. .readline() reads one line. Iterating directly over the file object reads line by line, which is the most efficient approach for large files since it does not load everything into memory at once.

.read() loads the whole file into memory as one string. .readline() reads a single line, newline included. .readlines() returns a list of every line. For anything that might be large, iterate the file object directly with for line in f: it pulls one line at a time and never holds the full file at once, which keeps memory flat no matter how big the file gets.

.read() returns the entire file as one string, so its memory cost scales with file size: fine for a config, a problem for a multi-gigabyte log. Iterating the file object, for line in f, reads one line per step and discards it before the next, so memory stays flat regardless of length. That is the pattern to default to for files of unknown size. .readlines() looks convenient but builds a list of every line at once, which has the same memory footprint as .read(), so it buys you nothing over the loop. The rule that prevents the classic out-of-memory bug: reach for the for line in f loop first, and only call .read() when you know the file is small.

python
with open("data.txt", "r") as f:
    content = f.read()          # entire file as one string

with open("data.txt", "r") as f:
    first_line = f.readline()   # one line at a time

with open("data.txt", "r") as f:
    lines = f.readlines()       # list of lines, each ending in "\n"

For large files, reading line by line is more efficient than loading everything at once:

python
with open("big_file.txt", "r") as f:
    for line in f:              # iterate the file directly, memory-efficient
        print(line.strip())     # strip() removes the trailing newline

Iterating directly over the file object (for line in f) is the most efficient way to read a large file.

JunoReading files.read() gives you the whole file as one string, .readline() gives you a single line. When the file might be big, loop with for line in f instead, it reads one line at a time so you never load the lot into memory. The .strip() on each line clears the trailing newline.
JunoReading files.read() loads everything at once, which is fine for small files and risky for large ones. Default to for line in f: it streams one line at a time and keeps memory flat whatever the size. .readlines() looks tidy but uses as much memory as .read().
JunoReading files.read() and .readlines() both hold the full file in memory, so they scale with file size. The for line in f loop reads and drops one line at a time, keeping memory flat, which is what stops the out-of-memory surprise on a file you did not size first. Reach for the loop by default, .read() only when you know it is small.

Writing files

"w" mode overwrites the file entirely if it exists. "a" mode adds to the end. .write() does not add a newline automatically; include "\n" explicitly at the end of each line. To write multiple lines at once, join them with "\n".join().

.write(s) writes one string and, unlike print(), adds nothing, no space and no newline, so you put the "\n" in yourself. .writelines(lines) writes each string in a list back to back, again with no separators added, so the newlines have to already be in your strings. Watch the mode: "w" clears the file the instant you open it, before you have written a thing, so opening with "w" when you meant "a" discards the old contents.

Two things about writing that cause real bugs. First, the data you write does not necessarily hit the disk straight away: the OS holds it in a buffer and writes in batches, and it is .close() (which with calls for you) that makes sure everything is on disk. If a long-running process writes and never closes, the tail of the output can sit in the buffer and be lost on a crash, so close the file or call .flush() at the points where the data has to be safe. Second, the mode decides the damage: "w" empties the file the moment it opens, before any write, so reaching for "w" when you wanted "a" destroys the previous contents with no warning. .writelines(lines) adds no separators of its own, so the newlines must already be in the strings.

python
with open("output.txt", "w") as f:
    f.write("Hello, world\n")

with open("output.txt", "a") as f:
    f.write("Another line\n")

"w" overwrites the file entirely if it exists. "a" adds to the end.

f.write() does not add a newline automatically, so include "\n" explicitly. To write multiple lines at once:

python
lines = ["Line one", "Line two", "Line three"]

with open("output.txt", "w") as f:
    f.write("\n".join(lines) + "\n")
JunoWriting files"w" creates or overwrites the file, "a" adds to the end without wiping it. .write() does not add a newline for you, so tack "\n" onto each line yourself. For a batch of lines, "\n".join(lines) stitches them together first.
JunoWriting files"w" clears the file the moment it opens, "a" appends, so picking the wrong one silently throws away the old contents. .write() adds nothing, no newline, so you supply "\n" yourself. .writelines() writes a list with no separators either, the newlines have to be in the strings already.
JunoWriting files"w" empties the file the instant it opens, so reaching for it instead of "a" destroys the previous contents with no warning. Writes sit in a buffer until .close() (which with calls) flushes them, so a process that writes and never closes can lose its tail on a crash. .write() and .writelines() add no newlines, that is on you.

Exceptions

When Python hits a problem it cannot handle, it raises an exception: an error that describes what went wrong and where. If you do not handle it, your program crashes and prints a traceback. The table below shows the most common exceptions you will encounter.

An exception is an object, and exceptions form a family tree: specific ones like FileNotFoundError are a kind of broader one (OSError), and almost everything you catch is a kind of Exception. That tree is what makes catching work: catching a parent type also catches its children. When one is raised, Python abandons the current line and walks back up through the calls that led there, looking for a matching except. If it finds none, the program stops and prints a traceback: the list of calls from where the error happened back to the top.

Exceptions form a class hierarchy (a family tree of types), and that tree is the whole game when you catch them: an except for a parent type also catches every type below it, so except Exception is a wide net and except FileNotFoundError is a narrow one. The one piece of the tree worth memorising is that KeyboardInterrupt (the user pressing Ctrl-C) and SystemExit (a clean shutdown request) sit beside Exception, not under it. That is deliberate: it means a broad except Exception lets those two through, so a Ctrl-C still stops your program even inside a catch-all. This is exactly why you reach for except Exception and never a bare except:, which catches those two as well and traps the user inside a program they are trying to quit.

Common exceptions you will encounter:

ExceptionWhen it happens
FileNotFoundErroropen() cannot find the file
ValueErrorFunction gets a value of the right type but wrong content, e.g. int("abc")
TypeErrorWrong type entirely, e.g. "hello" + 5
KeyErrorDictionary key does not exist
IndexErrorList index out of range
ZeroDivisionErrorDivision by zero
AttributeErrorObject does not have that attribute or method
JunoExceptions An exception is Python telling you something went wrong. You will meet a few constantly: FileNotFoundError, ValueError, KeyError, TypeError. Left unhandled they stop the program and print a traceback, which looks alarming but is really a map pointing straight at where it broke.
JunoExceptions Exceptions are objects arranged in a family tree, and catching a parent type also catches its children. When one is raised Python walks back up the calls looking for a matching except, and prints a traceback if it finds none. Knowing the common names, ValueError, KeyError, FileNotFoundError, lets you catch the right one.
JunoExceptions The type hierarchy is the whole point: except Exception is the wide net, a specific type is the narrow one. KeyboardInterrupt and SystemExit sit beside Exception, not under it, so a Ctrl-C still escapes except Exception. That is the reason to use it over a bare except:, which traps the user inside a program they are trying to quit.

try / except

Wrap code that might fail in a try block. If an exception occurs, the matching except block handles it instead of crashing. Be specific about which exception you catch: catching everything with a bare except: hides real bugs.

try/except catches a specific exception type and runs the handler instead of crashing. Naming the type matters: catch the one you can actually recover from, so an unrelated bug surfaces loudly rather than getting swallowed. Bind the exception with as e when you want to read its message. List several except clauses for different types and Python uses the first one that fits.

An except SomeType matches when the raised exception is that type or a subtype of it (a more specific kind underneath it in the tree), which is why catching a parent catches all its children. Python tries each except top to bottom and stops at the first match, so order them specific before general: a broad clause placed first will catch everything and the narrower ones below it never run. The discipline that keeps try/except from hiding bugs: catch the narrowest type you can handle, keep the try body down to the one line that can actually raise, and let everything else propagate. A try wrapped around twenty lines turns one expected failure into a mask over nineteen unexpected ones.

python
try:
    value = int("abc")
except ValueError:
    print("That's not a valid number")

Be specific about which exception you catch. Catching all exceptions with a bare except: hides bugs:

python
# bad, catches everything including programmer mistakes
try:
    result = do_something()
except:
    pass

# good, only catches what you expect and can actually handle
try:
    result = do_something()
except FileNotFoundError:
    print("File not found")
Junotry / except Put code that might fail in a try block, and the matching except runs instead of the program crashing. Name the exception you expect, like ValueError, rather than a bare except:, which catches everything and hides real bugs from you. Catch what you can actually deal with, and let the rest show.
Junotry / excepttry/except catches a named type and recovers instead of crashing; as e gives you the message. Catch the specific type you can handle so unrelated bugs still surface. Keep the try body small, wrapping twenty lines turns one expected failure into a mask over the rest.
Junotry / except An except matches its type and any subtype, and Python takes the first clause that fits, so order specific before general or the broad one shadows the rest. Catch the narrowest type, keep the try down to the line that can actually raise, and let everything else propagate. A wide try hides far more than the failure you meant to handle.

Catching multiple exceptions

You can handle different error types in separate except blocks, or catch several types in one block using a tuple. The as e part gives you access to the error message.

Stack several except clauses for different types and Python checks them top to bottom, taking the first that fits. To handle several types the same way, group them in a tuple: except (ValueError, ZeroDivisionError). as e binds the exception so you can read its message. Order matters: put the specific types before the general ones, or a broad clause higher up catches everything and the ones below it never run.

Order is the trap. Python takes the first except whose type matches, and a broad type matches its narrower relatives, so listing except Exception above except ValueError means the ValueError clause is dead code that never runs. Specific first, general last. Group types that share a response with except (A, B) as e. When you catch an error only to add context and pass it on, raise the new one with from: raise ValueError("bad config") from e. That keeps the original error visible underneath the new one in the traceback, so the report shows both the high-level meaning and the low-level cause instead of replacing one with the other.

python
try:
    data = int(user_input)
    result = 100 / data
except ValueError:
    print("Not a number")
except ZeroDivisionError:
    print("Can't divide by zero")

Or catch multiple in a tuple:

python
except (ValueError, ZeroDivisionError) as e:
    print(f"Input error: {e}")

as e binds the exception object to a name so you can inspect the message.

JunoCatching multiple exceptions Give each error type its own except block, and Python runs the first one that matches. To handle a few the same way, list them in a tuple: except (ValueError, ZeroDivisionError). The as e part hands you the error object so you can print its message.
JunoCatching multiple exceptions Multiple except clauses run top to bottom, first match wins, so list specific types before general ones or the broad one shadows the rest. A tuple, except (A, B), handles several with one block. as e binds the exception so you can read its message.
JunoCatching multiple exceptions Specific clauses go first: a broad type above a narrow one makes the narrow clause dead code. Group shared responses with except (A, B) as e. When you re-raise to add context, use raise NewError("...") from e so the original cause stays visible under the new error in the traceback instead of vanishing.

else and finally

else runs only if no exception occurred. finally always runs, whether or not there was an exception. finally is useful for cleanup that must happen no matter what.

else holds the code that should run only when the try body raised nothing. Keeping it out of the try makes the intent clear: you are not catching exceptions from the success path by accident. finally is the cleanup guarantee, it runs no matter what, whether the block succeeded, raised, was caught, or even hit a return. That is what makes it the right home for releasing a resource that has to be let go regardless of the outcome.

else runs only when the try body finished cleanly, which keeps the success-path code out of the try so it cannot accidentally trigger one of your except clauses. finally runs unconditionally: after success, after a caught exception, after an uncaught one on its way up, even after a return in the try. That last case hides a trap worth knowing: a return inside finally overrides a return from the try and, worse, silently swallows an exception that was propagating, so the error vanishes with no trace. Keep finally to cleanup, never return from it. For files, with already gives you this guarantee, so reach for finally mainly when you are holding a resource that has no context manager.

python
try:
    with open("data.txt") as f:
        content = f.read()
except FileNotFoundError:
    print("File not found, using defaults")
    content = ""
else:
    print("File loaded successfully")
finally:
    print("Done attempting to load file")   # always runs

finally is most useful for cleanup (closing connections, releasing locks) even when you are already using with for files.

Junoelse and finallyelse runs only when the try raised nothing, a tidy place for the rest of the success path. finally runs every time, exception or not, which makes it the spot for cleanup that has to happen whatever the outcome. With files with already covers the cleanup, so you will reach for finally less than you might expect.
Junoelse and finallyelse runs only on the clean path, keeping success code out of the try so it cannot trip your own except. finally runs unconditionally, even past a return, so it is your cleanup guarantee. For files with already does this, so save finally for resources without a context manager.
Junoelse and finallyelse is the clean-path code that stays out of the try so it cannot fire your except clauses. finally runs no matter what, but never return from it: that override swallows a propagating exception and the error vanishes silently. Keep finally to cleanup, and mostly only when there is no with to lean on.

raise

You can raise exceptions yourself with raise. This is how you make your functions signal problems clearly to callers instead of silently returning a wrong value.

raise ExceptionType("message") throws an exception from your own code, which is how a function reports a bad input instead of limping on with a wrong value. Inside an except block, a bare raise (no argument) re-throws the exception you caught, useful when you want to log it or react and still let it travel up. Raising deliberately makes a function's failure modes part of its contract, so callers can catch and handle them by name.

raise SomeError("message") reports a failure on purpose, the right move when a function gets input it cannot honour: refusing loudly beats returning a wrong answer that surfaces as a mystery later. Inside an except, a bare raise re-throws the exception you caught with its original traceback intact, so you can log and re-raise without erasing where it came from. When you want to translate a low-level error into a domain one, use from: raise ConfigError("bad settings") from e keeps the original underneath the new one in the report, so a reader sees both the meaning and the cause. The rule of thumb: re-raise to add context, never to hide it.

python
def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

This makes your functions explicit about what they expect and signals problems clearly to callers.

Junoraiseraise ExceptionType("message") lets your own code flag a problem out loud instead of carrying on with a bad value. It is how a function says "I cannot work with this" so the caller deals with it. Clear and early beats a wrong answer that shows up much later.
Junoraiseraise ExceptionType("message") reports a failure from your code, making a function's error cases part of how callers use it. A bare raise inside an except re-throws what you caught, keeping the original traceback, so you can log and still let it travel up.
Junoraise Raise deliberately when input cannot be honoured: refusing loudly beats a wrong answer that surfaces later as a mystery. A bare raise re-throws with the original traceback intact. To translate a low-level error into a domain one, use raise DomainError("...") from e so the cause stays visible, re-raise to add context, never to bury it.

Custom exception classes

For larger programs, you can define your own exception types by inheriting from Exception. This lets callers catch your specific errors separately from other kinds of errors.

A custom exception is your own class that inherits from Exception (it becomes a new kind of exception, so it slots into the same family tree). The payoff is a name callers can catch precisely, separate from built-in errors. For a group of related failures, make one base class and subclass it for each specific case: callers catch the base to handle the whole group, or a subclass to single one out.

Subclass Exception, not BaseException (the root that also covers Ctrl-C and shutdown, which you do not want your error grouped with). The reason to define your own type rather than reuse ValueError is the family tree: build a base like PaymentError and subclass it for each mode (InsufficientFundsError, CardDeclinedError), and a caller can catch PaymentError to handle the whole category or the subclass for one case, without parsing message strings. Give the class an __init__ carrying the relevant fields (the account, the amount) so the handler can inspect what happened in code instead of scraping the text, which makes the error something a program can act on, not only print.

python
class InsufficientFundsError(Exception):
    pass

class BankAccount:
    def __init__(self, balance):
        self.balance = balance

    def withdraw(self, amount):
        if amount > self.balance:
            raise InsufficientFundsError(
                f"Cannot withdraw {amount}, balance is {self.balance}"
            )
        self.balance -= amount
python
try:
    account.withdraw(1000)
except InsufficientFundsError as e:
    print(f"Transaction declined: {e}")
JunoCustom exception classes Make your own error type by inheriting from Exception, and a single empty class body is enough to start. It gives callers a name to catch that is yours, separate from built-in errors, so your program's problems stand on their own.
JunoCustom exception classes A custom exception is a class inheriting from Exception, giving callers a precise name to catch. For related failures, make one base class and subclass it per case: catch the base for the whole group, a subclass for one. You can add fields to carry details beyond the message string.
JunoCustom exception classes Subclass Exception, not BaseException, so your error is not lumped in with Ctrl-C. The reason to define your own is the tree: a PaymentError base with specific subclasses lets a caller handle the category or one case without scraping message text. Put the relevant fields in __init__ so handlers act on data, not strings.

JSON

JSON is the format that everything speaks: APIs, config files, data exports. Python's json module handles it directly. json.load() reads JSON from a file into a Python dictionary or list. json.dump() writes a dictionary or list back to a file as JSON.

The pairs split by what they touch. json.load() / json.dump() work with a file object, json.loads() / json.dumps() (the ones with the s) work with a string in memory. Pass indent=2 to dump/dumps when a human will read the output, otherwise it comes out on one line. Invalid JSON raises json.JSONDecodeError, which is a kind of ValueError, so a handler catching ValueError already covers it.

The four names are two operations crossed with two sources: load/dump for files, loads/dumps for strings (the s is "string"). The detail that catches people in production is what JSON cannot represent. It has no notion of a date, a set, or a Decimal, so json.dumps(datetime.now()) raises TypeError. You handle it by passing default=, a function dump/dumps calls on any value it does not know, which should return something JSON can hold, usually str(value). The other rule worth holding: numbers round-trip as float, so a value you wrote as Decimal for exactness comes back as a float and loses it. Never store money or anything needing exact decimals (see the Numbers chapter) as a plain JSON number.

Read JSON from a file:

python
import json

with open("config.json", "r") as f:
    config = json.load(f)    # parses JSON into a Python dict/list

print(config["setting"])

Write JSON to a file:

python
import json

data = {"name": "Alice", "score": 87, "active": True}

with open("output.json", "w") as f:
    json.dump(data, f, indent=2)    # indent= makes it human-readable

JSON to Python type mapping:

JSONPython
object {}dict
array []list
string ""str
numberint or float
true / falseTrue / False
nullNone

To convert between JSON strings and Python objects without touching a file:

python
import json

# string to Python
data = json.loads('{"name": "Alice", "score": 87}')

# Python to string
text = json.dumps({"name": "Alice", "score": 87}, indent=2)

json.load() reads from a file object. json.loads() (with an "s") reads from a string.

JunoJSONjson.load(f) turns JSON in a file into Python dicts and lists, json.dump(data, f) writes them back out. The s versions, json.loads and json.dumps, do the same for a string instead of a file. Add indent=2 when you want the output readable.
JunoJSONload/dump handle files, loads/dumps handle strings, the s is for string. indent=2 makes it human-readable. Bad JSON raises json.JSONDecodeError, which is a ValueError, so catching ValueError already covers a parse failure.
JunoJSON Two operations crossed with two sources: load/dump for files, loads/dumps for strings. JSON has no date, set, or Decimal, so dumping those raises TypeError unless you pass default=. And numbers round-trip as float, so never store money as a plain JSON number, it loses its exactness on the way back.

In practice

A save/load pattern for a small game: write state to JSON, load it back on the next run, and fall back to defaults if no save file exists yet:

python
import json

SAVE_FILE = "save_game.json"

def save_game(player_data: dict) -> None:
    with open(SAVE_FILE, "w") as f:
        json.dump(player_data, f, indent=2)
    print("Game saved.")

def load_game() -> dict:
    try:
        with open(SAVE_FILE, "r") as f:
            return json.load(f)
    except FileNotFoundError:
        print("No save file found, starting fresh.")
        return {"name": "Player", "score": 0, "level": 1}

state = load_game()
state["score"] += 50
save_game(state)

Loading a config file and saving results, with specific exception handling for each failure mode:

python
import json

def load_config(path: str) -> dict:
    try:
        with open(path, "r") as f:
            return json.load(f)
    except FileNotFoundError:
        raise FileNotFoundError(f"Config file not found: {path}")
    except json.JSONDecodeError as e:
        raise ValueError(f"Invalid JSON in {path}: {e}")

def save_results(results: list[dict], path: str) -> None:
    with open(path, "w") as f:
        json.dump(results, f, indent=2)
    print(f"Saved {len(results)} result(s) to {path}")

config = load_config("experiment.json")
results = [{"epoch": 1, "loss": 0.82}, {"epoch": 2, "loss": 0.61}]
save_results(results, "results.json")

A structured log writer that appends timestamped entries to a file, with a top-level handler that catches and logs unexpected failures:

python
import json
from datetime import datetime

LOG_FILE = "run.log"

def log(level: str, message: str) -> None:
    ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    entry = f"[{ts}] [{level.upper():7}] {message}\n"
    with open(LOG_FILE, "a") as f:
        f.write(entry)

def process(config_path: str) -> None:
    log("info", f"Starting job, config: {config_path}")
    try:
        with open(config_path) as f:
            config = json.load(f)
        log("info", f"Loaded config: {config}")
    except FileNotFoundError:
        log("error", f"Config not found: {config_path}")
        raise
    except json.JSONDecodeError as e:
        log("error", f"Bad JSON in config: {e}")
        raise

try:
    process("config.json")
except Exception as e:
    log("critical", f"Job failed: {e}")

Re-raising after logging preserves the original traceback for the caller. The top-level except Exception catches anything that slipped through, logs it as critical, and lets the process exit cleanly.