Lecture notes. Written from the material of Berkeley Stat 243, licensed CC BY 4.0. These are notes, not a transcript: the material has been reorganised and rewritten. This adaptation carries the same licence, and the original is linked above.
42. Good Practices, Debugging, and Reproducibility¶
What this covers¶
This chapter is about writing Python code that survives contact with someone else — including
your own future self. It answers three practical questions that come up as soon as an assignment
or analysis grows past a few dozen lines: how should code be styled and structured so it doesn't
quietly accumulate bugs, how do you find a bug once one has appeared, and how do you organize a
project so that the result can be rerun, by you or anyone else, later? It assumes you can already
write basic Python — functions, loops, if statements — and have used a shell and a text editor or
IDE.
None of this is Python-specific in spirit; the same practices apply in R, in a shell script, or in any language you end up using. Only the syntax of the examples is Python's.
Code style and structure¶
Use an editor or IDE that understands the language you're writing (VS Code, Emacs, Sublime, vim, or RStudio's built-in editor, which can also run Python). The concrete payoff is color-coded syntax, automatic indentation, parenthesis matching, line numbers for locating bugs, and the ability to run and debug code from inside the editor rather than by copy-pasting into a terminal.
For the code itself, the PEP 8 style guide is the reference for Python. A few points are worth calling out because they double as bug prevention:
- Header and docstrings. Put who/when/what metadata in a comment at the top of a file, and write
a docstring for every public module, class, function and method. For non-public items a one-line
comment after the
defis enough. - Indentation and whitespace. Python enforces indentation, which is a feature: it forces visual
symmetry, and broken symmetry is often a sign of a bug. Use 4 spaces, not tabs. Put whitespace
around operators and between arguments even when it isn't required for parsing —
a/y * xis safer to read thana/y*x, because in a squeezed expression it's easy to lose track of the actual order of operations and introduce a bug without noticing. - Comments should earn their place. Don't narrate the obvious (
x = x + 1 # increment x). Do explain: what a block of code is for, any genuinely complicated piece of code (a hairy regular expression, say), and any constant whose value isn't self-explanatory. Comments should generally be full sentences, because you will not remember your own reasoning in six months any better than a stranger would. - Naming. PEP 8's convention is
UpperCamelCasefor classes andsnake_casefor functions, methods and variables, with a leading underscore for non-public names. Names should be informative without being long, and should not shadow a built-in (len, for instance) — though Python's namespace system limits the damage when a clash is unavoidable. Functions should be named with verbs (calc_loglik, notloglikorloglik_calc), because a function is the verb of a program: it does something. - Line length and layout. Keep code lines under about 79 characters and comment/docstring lines under 72. For a long pipeline of method calls, wrapping the whole expression in parentheses lets you split it across lines and comment each step:
newdf = (
pd.read_csv('file.csv')
.rename(columns={'STATE': 'us_state'}) # adjust column names
.dropna() # remove some rows
)
Beyond syntax, a few structural habits keep a project from becoming unmanageable:
- Break a task into core units, and write reusable code for each rather than lines that operate on one specific data object. A function's inputs and outputs are declared, so someone reading it doesn't have to read through the body to know what it does — a script that just runs top to bottom gives no such guarantee.
- Keep a single copy of each piece of logic (with version control, so you never need two files that are "almost" the same). Smaller functions are easier to debug, easier to understand, and compose the way UNIX utilities do.
- A function should be modular (one task), meaningfully named, and documented — purpose, inputs, outputs. Write a unit test for it.
- Don't hard-code numbers — even ones you're confident won't change, like
speed_of_light = 3e8— because a named variable is more readable and more searchable than a bare literal repeated in several places. - Practice defensive programming: check function inputs and warn if the code will do something
the caller might not expect; give reasonable default arguments; document the valid range of
inputs; check that the output is valid; and stop execution with an informative error message when
a check fails. (Recall that in Python, an
ifcondition is falsy only forFalse,0,None, or an empty list/tuple/string — everything else is truthy, including things you might not expect.) - Avoid code that only works on one operating system. Have others review your code, and review theirs. And once a project's requirements have stabilized, consider a deliberate rewrite: code written while the plan was still moving often no longer fits the job it ends up doing.
- Break large software projects into separate files (roughly 2000–3000 lines each) with meaningful names, grouping related functions together.
For style specifically, a linter applies a tool to enforce these conventions automatically.
ruff (or black) will first flag syntax problems (ruff check file.py) and then reformat the file
to a consistent style (ruff format file.py). Running diff on the before/after shows what changed:
fixing an unspaced =, re-indenting a misplaced comment, adding a space around * — exactly the
kind of small inconsistency that is easy to introduce and easy to automate away.
Assertions, exceptions, and testing¶
These three tools exist for the same reason: to make it hard for a bug to hide.
Exceptions are what Python raises when the syntax was fine but running the code failed — a missing file, an unreachable URL, invalid input. There are two sides to using them. If you're writing code whose job is to do something and it can't, you want it to fail loudly and informatively — either by letting the exception it triggered propagate (perhaps with a clearer message attached), or by explicitly detecting a bad situation and raising your own:
def myfun(val):
if val <= 0:
raise ValueError("`val` should be positive")
myfun(-3) # ValueError: `val` should be positive
The other side is when you want a piece of code to keep going even if something inside it fails —
for instance, a script downloading hundreds of URLs shouldn't necessarily stop because one of them
doesn't respond. Wrap the risky call in try/except:
import os
def myfun(filename):
try:
with open(filename, "r") as file:
text = file.read()
except Exception as err:
print(f"{err}\nCheck that the file `{filename}` can be found "
f"in the current path: `{os.getcwd()}`.")
return None
return text.lower()
Sometimes you want to intercept an error just to add context, then let it propagate anyway —
except ...: print(...); raise (a bare raise re-raises the exception currently being handled).
Assertions are a quick way to raise AssertionError when a condition you expect to hold turns
out not to. They're aimed at development-time sanity checks — verifying pre- and post-conditions —
and can be disabled for production runs to recover the performance cost. A good assertion carries
a message that says what went wrong:
Common patterns: assert x in y, assert x is not y, assert isinstance(x, sometype),
assert all(x), assert any(x). In a data-analysis pipeline, assertions are a natural place to
check that a dataset has the dimension you expect, or that no unexpected missing or extreme values
have crept in.
Testing is what assertions look like once you decide to keep them: instead of a check you type
interactively, you write it into a test file that anyone (including a CI system) can rerun. Unit
tests target one small piece of code, typically a single function — which is exactly what small,
modular functions make easy. The pytest package is the standard tool:
import pytest
import numpy as np
import dummy
def test_numeric():
assert dummy.add_one(3) == 4
def test_numpy_array():
assert np.all(np.equal(dummy.add_one(np.array([3, 4])), np.array([4, 5])))
def test_bad_input():
with pytest.raises(TypeError):
dummy.add_one('hello')
def test_warning():
with pytest.warns(UserWarning, match='complex'):
dummy.add_one(1 + 3j)
pytest test_dummy.py runs the file; run with no argument at all and it searches the current
directory (and subdirectories) for anything with "test" in the name. A test suite is worth writing
even before the code it tests, in some people's practice — it clarifies what the code is supposed to
do, and it becomes a second form of documentation.
Continuous integration (CI) is running your tests automatically whenever the code changes,
rather than trusting that you remembered to run them locally. The standard mechanism is GitHub
Actions: a YAML file in .github/workflows/ specifies when to run (e.g. on every push), how to set
up the environment, and what to run.
on:
push:
branches:
- main
jobs:
CI:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.12
- name: Install package and pytest
run: |
pip install pytest
pip install --user .
- name: Run tests
run: |
cd mytoy
pytest
When triggered, GitHub runs these steps in a fresh virtual machine (the runner), which is exactly what catches the common failure mode of "it works on my machine."
AI-assisted coding tools¶
IDEs now commonly integrate AI coding assistance directly — inline completion, accept/reject suggestions, and a chat window with Chat/Edit/Agent modes that can rewrite or generate code from a prompt, given files or a directory as context (GitHub Copilot and Gemini Code Assist as VS Code extensions, or Cursor, which is built on VS Code). This is a faster workflow than copy-pasting from a chatbot, but the caution is the same: don't accept a large chunk of generated code you don't understand — it is still prone to bugs and hallucination, and code you don't understand is code you can't maintain or debug. A reasonable hierarchy of trust: fine for straightforward syntax where checking the output is enough (formatting a plot); usable for small, testable pieces (a regular expression) where you can verify behavior; and for anything larger — real analysis or algorithm code — you should check and understand it fully before relying on it.
Version control¶
Use version control — git — even on a solo project; it is the nearest thing to a time machine you will get. Pair it with an issue tracker (or at minimum a to-do file) for changes you want to make later, and keep good commit messages alongside running notes on the project. Git itself is covered in its own unit of this course.
Debugging¶
General strategies¶
- Read the traceback from the bottom up. The bottom line is usually the actual error; higher lines are the chain of calls that led there. Often it's less inscrutable than it looks once you read it carefully — and a web search on the exact error message (in quotes) is a legitimate first move.
- When an error occurs inside a nested chain of calls, the call stack is that chain, and the traceback shows it. Focus on the function that was executing when the error hit — but if that function isn't one you wrote, the actual mistake is usually in the arguments your code passed into the last function-you-wrote before the trace descends into library code.
- Fix errors top-down when several are reported at once: a cascade of errors is very often one root cause producing many symptoms.
- Check whether the bug is reproducible — does it happen the same way every time? Restarting the interpreter can reveal a scoping bug where code was accidentally relying on a global variable.
- If you can't localize the error from the message alone, use bisection: build the code up in pieces (or tear a failing version down in pieces) until you've isolated the smallest version that still fails.
- Code written as small, modular functions can be tested individually — the bug is usually in what gets passed between them.
- Failing that, fall back to print statements (the debugging strategy of the 1970s, still occasionally the right tool) or step through the code line by line — though line-by-line execution becomes impractical once the error is buried in nested calls, in scoping behavior, or inside compiled library code you can't step into.
Using pdb¶
Python's pdb (with IPython's ipdb as a friendlier wrapper, and VS Code's built-in graphical
debugger as another option) can be entered in several ways: inserting breakpoint() at a point of
interest; calling pdb.pm() right after an exception to jump to where it occurred (%debug in
IPython/Jupyter does the same); running a function under pdb.run(); or starting the interpreter
itself under the debugger with python -m pdb file.py.
Here's the worked example from lecture: a function that fits a regression separately within each
stratum of some grouped data, with breakpoint() inserted at the line that builds the per-stratum
subset.
n runs the current line and stops at the next one; c continues to the next breakpoint (here, the
top of the next loop iteration, since breakpoint() sits inside the loop); p i prints a variable.
Stepping through every iteration by hand would be tedious, so instead it's more efficient to let the
code run and only stop once an error actually occurs — post-mortem debugging, via pdb.pm()
after the failure:
This drops you inside whatever internal function the error actually happened in — often a compiled
NumPy routine, which the debugger can't step into further. Typing u ("up") repeatedly walks back
up the call stack until you reach code you wrote:
(Pdb) u
> .../run_no_break.py(10)fit()
-> model = statsmodels.api.OLS(sub['y'], statsmodels.api.add_constant(sub['x']))
and printing the variables at that frame reveals the actual problem:
Stratum 29 had no data in it — that's why fitting a regression to it failed. The bug wasn't in the regression call; it was upstream, in whatever produced strata with zero observations.
Useful pdb commands: h/help, l/list (show surrounding code), n/next, s/step (step
into a call), r/run (finish the current function), unt/until (run to the next line, useful
for letting a loop finish), c/continue, b/break and tbreak (one-time breakpoint), where
(show the call stack), u/up and d/down (move through it), p/print, and q/quit. You
can also invoke pdb.run("run.fit(...)") (step in immediately with s), or set a breakpoint on a
line number or function name (b 9, b fit) when starting a module under python -m pdb, which
avoids editing the source to insert breakpoint().
Common causes of bugs¶
Parenthesis mismatches; == where = was meant; comparing floating-point numbers exactly with
== (dangerous, because they're only represented to finite precision — 1/3 == 4*(4/12 - 3/12) is
False); expecting a scalar but getting an array back; silent type conversion where none was
wanted, or the reverse; using the wrong function or variable name; passing positional arguments in
the wrong order; and forgetting to define a variable inside a function, so that Python's lexical
scoping quietly reaches out to a global variable of the same name — which may work while you're
developing (because the global happens to exist) and break the moment you restart the interpreter.
Python also silently drops array or matrix dimensions that become redundant, which can confuse code
downstream that expected a particular shape.
Tips for avoiding bugs¶
- Defensive programming, worked as a function: check the type, warn rather than silently producing nonsense, and raise where there's no sensible fallback.
import warnings
def mysqrt(x):
if isinstance(x, str):
raise TypeError(f"What is the square root of '{x}'?")
if isinstance(x, (float, int)):
if x < 0:
warnings.warn("Input value is negative.", UserWarning)
return float('nan') # avoid a complex result
return x**0.5
raise ValueError(f"Cannot take the square root of {x}")
- Catch run-time errors with
try/exceptrather than letting one bad case kill a whole loop. Returning to the stratified-regression example: some strata may have too few observations to fit a model. Wrapping the fit intry/exceptlets the loop report the failure for that stratum and move on, instead of stopping the entire analysis:
params = np.full((n_cats, 2), np.nan)
for i in range(n_cats):
sub = data[data['cats'] == i]
try:
model = statsmodels.api.OLS(sub['y'], statsmodels.api.add_constant(sub['x']))
fit = model.fit()
params[i, :] = fit.params.values
except Exception as error:
print(f"Regression cannot be fit for stratum {i}.")
Here the stratum with no observations could have been checked for in advance; in general, though, you may have no cheap way to know ahead of time whether a call will fail.
- Maintain dimensionality. NumPy drops axes that become length-one, which is often convenient and occasionally the source of a bug:
mat = np.array([[1, 2], [3, 4]])
np.sum(mat, axis=0) # sums columns, as intended
mat2 = mat[1, :] # a 1-D row, not a 1x2 matrix
np.sum(mat2, axis=0) # sums *elements*, not columns — silently wrong shape
if len(mat2.shape) != 2:
mat2 = mat2.reshape(1, -1)
It's easier to avoid ever dropping the dimension than to add checks everywhere downstream for whether it happened.
- Find and avoid global variables. Code that depends on a variable it didn't create or receive as an argument breaks whenever that global changes — often silently, because the person changing it doesn't know some function depends on it. A useful test is to delete the suspected global and see whether the function that used it still works:
del x # simulate a fresh session
def f(z):
y = 3
print(x + y + z) # NameError once x is gone, if f relied on it being global
f(2)
- Miscellaneous: prefer existing, well-tested library functionality over rewriting it; write small modular functions so you never debug the same logic twice; get the code correct before making it efficient (write the simple, obviously-correct version first — plain loops are fine — then optimize and check the two versions agree); plan for special cases in advance; write tests early; build up a program in small steps, checking after each one that nothing broke; and have someone else review the code, ideally while it's still being written.
Tips for running long analyses¶
Save output at intermediate steps — including the random seed state, e.g. via pickle.dump() — so
a long job can be restarted rather than rerun from scratch if it or the machine fails partway.
Always run on a small subset of the problem first, to confirm the code works and saves what it
should, before setting off a job that will run for hours or days.
Reproducible research¶
"Reproducible research" has become a live concern as research projects have grown more complex, published papers have omitted enough detail to make replication difficult, and some published results have simply failed to hold up (including, in some cases, outright fraud). Provenance — being able to trace an analysis's steps back to its origin — is the underlying idea, and it splits into two related but distinct properties:
- Reproducibility: a second person or group, using the same data, methods and code, gets the exact same result. This is harder than it sounds, and it gets harder as time passes — even for the original author.
- Replicability: a second person, using new data to answer the same scientific question, gets a consistent result.
An open question worth sitting with: what would it actually take for a piece of work to satisfy either property, and where does that break down in practice?
Basic strategies¶
- Give each project a directory with meaningfully named, standardized subdirectories —
code,data,paper— following, for example, JASA's template repository. - Separate the pipeline into stages: a file for pre-processing, one or more for analysis, one for
producing figures and tables — named so the order is obvious (
1-prep.py,2-analysis.py,3-figs.py). Pre-processing that's expensive should write its output to a data file the analysis script reads in, rather than being rerun every time. The figure-producing code should read from a saved file of exactly the objects it needs (a pickle file, say) and reproduce the manuscript figures exactly — or the whole pipeline can be a Quarto or Jupyter document instead. - Keep a running lab book: dated notes on what was done, where data came from and when, what pre-processing was applied, what each code file is for, and version numbers for the data with a description of what changed between them. Where possible, express any change to the data as code that transforms a fixed baseline dataset, rather than editing the data by hand.
- Record the software environment: OS version, language version, and package versions
(
pip list/conda listto see them,pip freeze > requirements.txtorconda env export > env.ymlto capture them as something that can rebuild the environment elsewhere withpip install -r requirements.txtorconda env create -f env.yml). These files can embed operating-system-specific details that don't transfer cleanly across systems, but recording versions is still worth doing.
Formal tools¶
- A complete workflow can sometimes live entirely inside one Quarto document or Jupyter notebook.
- Workflow/pipeline managers (e.g. the
makeutility, ordinarily used for compiling code, repurposed for reproducible pipelines; or tools such as Drake) can express the dependency structure between pipeline stages explicitly. - A project can be organized as an installable Python or R package.
- Package management pins the versions of dependencies: Conda environments or virtualenvs for
Python;
renvorpackratfor R (the once-popularcheckpointpackage no longer works, since it relies on CRAN snapshots that stopped being available after January 2023). - When a project spans more than one language or tool, containers (Docker being the standard) package an entire software environment — versions and all — so it can be shared and rerun elsewhere; they underlie tools like GitHub Actions and the Binder project. Conda can often do a lighter-weight version of the same job when the dependencies are not Python-specific.
Sources¶
- Fall 2025:
units/unit4-goodPractices.qmd— overview, "1. Good coding practices," "2. Debugging and recommendations for avoiding bugs," and "4. Reproducible research." This is the primary source for this chapter; it is the clearest and most recent version of the material (it adds the AI-assisted-coding-tools section and a note on operating-system portability of environment files that fall 2024 lacks). - Fall 2024: the same four sections of
units/unit4-goodPractices.qmd— nearly identical to fall 2025; used to confirm which material is stable across offerings. - Stat243 fall 2021:
units/unit4-goodPractices.pdf, "1 Good coding practices" and "4 Reproducible research" — the R-flavored predecessor of the same unit. This file is a model reconstruction of a PDF with no text layer (markedfidelity: reconstructedin its front matter) and was used only to confirm that debugging and testing were, in that offering, deferred to a lab session rather than covered in the notes themselves — not as a source of new material.
Not used here, and why: the task also supplied fall 2026's units/unit4-programming.qmd
(overview plus sections on text manipulation and regex, reading data into Python, output, operating-
system interaction, modules and packages, types and data structures, programming paradigms, OOP,
functional programming, and memory and copies). In fall 2026 the course renumbered its units: good
coding practices, debugging and reproducibility moved to that year's Unit 3, while Unit 4 was
repurposed for general Python programming concepts that, in the 2024 and 2025 offerings, were taught
as a separate "Programming" unit with its own line-for-line matching set of topics. Reproducing that
material here would duplicate the treatment of the same lecture content given elsewhere from its
clearer, unabridged lineage; it is not covered in this chapter.
No slides, transcript, or exercises were supplied for this task — only these converted lecture notes.
← 41. Bash Shell Basics and Pipelines · Contents · 43. Programming Language Mechanics →