Data Analysis with Python (pandas, polars, DuckDB) Module 1 – The data-analysis workflow and your toolkit
Set up your analysis environment and lock file
Give each analysis its own environment: a uv project, exact versions in a lock file, a check before every run, and the choice between scripts and notebooks.
What you will learn
- Create a project for an analysis with uv, with exact versions in pyproject.toml and uv.lock
- Check before every run that the installed packages match the lock file
- Explain how an upgrade of Python or of a library can change the numbers an analysis prints
- Choose between a script, a Jupyter notebook and a marimo notebook for a task
Before you start
What you’ll learn · Before you start
What you will learn
- Create a project for an analysis with uv, with exact versions in pyproject.toml and uv.lock
- Check before every run that the installed packages match the lock file
- Explain how an upgrade of Python or of a library can change the numbers an analysis prints
- Choose between a script, a Jupyter notebook and a marimo notebook for a task
Before you start
On this page
An analysis environment is the Python interpreter plus the exact version of every library your code imports. It decides your numbers as much as your code does: the same file can print a different total under another version of Python or pandas, and nothing in the file shows it. A result someone else can repeat therefore comes with its environment written down.
This lesson sets that up the way this track does it. Each analysis gets its own project folder, created with uv, with the exact versions recorded in a lock file. A short check runs before the analysis and refuses to go on in an environment that does not match. Last, it compares the three common ways to run analysis code: a script, a Jupyter notebook and a marimo notebook.
The same code, different numbers
Here are ten payments of 0.10 each, added up three ways:
# Ten payments of 0.10 each, added up three ways.
import math
payments = [0.1] * 10
total = 0.0
for amount in payments:
total += amount # one addition at a time, rounded after each step
print("loop, one + at a time:", total)
print("sum(): ", sum(payments))
print("math.fsum(): ", math.fsum(payments))
print("loop total == 1.0: ", total == 1.0)
print("sum() == 1.0: ", sum(payments) == 1.0) Output
loop, one + at a time: 0.9999999999999999 sum(): 1.0 math.fsum(): 1.0 loop total == 1.0: False sum() == 1.0: True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 payments_sum.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The loop adds one payment at a time, and every addition rounds its result to the nearest value a float can hold, so
the small errors pile up to 0.9999999999999999. sum() prints 1.0 because Python 3.12 switched it to a more accurate
algorithm for floats (sum(),
What’s New In Python 3.12). Up to Python 3.11, sum() added from
left to right exactly like the loop and printed 0.9999999999999999 as well. A report that compared a sum() with
an expected total using == gave a different answer after that upgrade, without a line of its code changing.
math.fsum() gives the correctly rounded total in every version. For money, whole paise or cents stored as
integers, or decimal.Decimal, avoid the question altogether.
Libraries change more often than Python does. pandas 3.0 stores text columns with a dedicated str type where
pandas 2 used NumPy’s general object type, and a str column refuses a value that is not text, so
series[0] = 5 raises TypeError where pandas 2 accepted it
(pandas 3.0 release notes,
string migration guide). Changes like these are
announced in each library’s release notes. With the versions written down, you take them when you decide to, not on
the day a colleague’s computer happens to install a newer release.
One project per analysis
uv is a free, open-source tool from the company Astral that creates projects and virtual environments, installs packages and records their exact versions; install it once, the way its documentation describes for your operating system. Three uv commands set up an analysis project with the versions this track uses and build it again elsewhere:
$ uv init --no-package --python 3.14.8 sales-analysis
Initialized project `sales-analysis` at `~/projects/sales-analysis`
$ cd sales-analysis
$ uv add pandas==3.0.6 polars==1.44.2 duckdb==1.5.6 numpy==2.5.3 pyarrow==25.0.1
Using CPython 3.14.8 interpreter at: …/bin/python3
Creating virtual environment at: .venv
Resolved 10 packages in 444ms
Downloading duckdb (14.8MiB)
Downloading pyarrow (34.2MiB)
Downloading pandas (9.7MiB)
Downloading polars-runtime-32 (41.3MiB)
Downloading numpy (5.2MiB)
Downloaded numpy
Downloaded pandas
Downloaded duckdb
Downloaded pyarrow
Downloaded polars-runtime-32
Prepared 8 packages in 5.41s
Installed 8 packages in 120ms
+ duckdb==1.5.6
+ numpy==2.5.3
+ pandas==3.0.6
+ polars==1.44.2
+ polars-runtime-32==1.44.2
+ pyarrow==25.0.1
+ python-dateutil==2.9.0.post0
+ six==1.17.0
$ ls -A
.git
.gitignore
.python-version
.venv
main.py
pyproject.toml
README.md
uv.lock
$ mkdir ../second && cp pyproject.toml uv.lock .python-version ../second/
$ cd ../second
$ uv sync --locked
Using CPython 3.14.8 interpreter at: …/bin/python3
Creating virtual environment at: .venv
Resolved 10 packages in 11ms
Installed 8 packages in 120ms
+ duckdb==1.5.6
+ numpy==2.5.3
+ pandas==3.0.6
+ polars==1.44.2
+ polars-runtime-32==1.44.2
+ pyarrow==25.0.1
+ python-dateutil==2.9.0.post0
+ six==1.17.0 What each step did:
uv init --no-package --python 3.14.8 sales-analysiscreated the folder with apyproject.tomlthat describes the project, a.python-versionfile that pins the interpreter, a startermain.py, aREADME.md, a.gitignoreand a Git repository.--no-packagesays this is an analysis, not a library that needs to be installable.uv addwith exact versions wrote those requirements intopyproject.toml, created the virtual environment.venvwith CPython 3.14.8, installed the packages into it and wroteuv.lock. It resolved 10 packages: the five named,polars-runtime-32(the compiled engine thatpolarsinstalls),python-dateutilandsix(which pandas needs),tzdata(which pandas needs only on Windows and in browsers) and the project itself. On this Mac it installed 8 of them.uv sync --lockedbuilt the environment again in a fresh copy of the folder from three files alone, with the same 8 packages at the same versions. That is what a teammate’s computer, or yours next year, does. Ifpyproject.tomlhas changed since the lock was written,--lockedstops with an error instead of quietly writing a new lock.
After that, uv run python analysis.py runs any file in the project’s environment; uv first checks that .venv
still matches the lock and brings it up to date, so you never activate anything by hand.
Two of the files matter most, and both appear in the tabs of the check in the next section. pyproject.toml says
what the project wants; here every requirement is an exact == version, which suits an
analysis whose numbers must not move. uv.lock records the exact version of every package, the dependencies of your
dependencies included, for every platform at once: uv’s documentation calls it a universal lock file. The copy shown
in the next section leaves out the lists of download addresses and file hashes that the real file holds for each
package.
Commit pyproject.toml, uv.lock and .python-version with your code. Leave .venv out (the .gitignore that uv
wrote already does): for this project it takes 387 MB on disk, and uv sync rebuilds it in seconds.
Without uv, Python’s own venv and pip do the same job in more steps: python3 -m venv .venv, then
.venv/bin/python -m pip install pandas==3.0.6 and the rest, then .venv/bin/python -m pip freeze > requirements.txt to record the versions, and pip install -r requirements.txt on the next computer. A
requirements.txt made this way records what was installed on one computer, not a resolution for every platform.
Check the environment before every run
A lock file only helps if the analysis actually runs in the environment it describes. This program compares the
versions in uv.lock with what the running Python has installed, using only the standard library (tomllib reads
the lock and importlib.metadata reads the installed versions), lists every difference it finds and then stops
with an error. Put it before the analysis: uv run python check_env.py && uv run python analysis.py, or paste it into the
first cell of a notebook.
env/check_env.py
# Stop an analysis before it runs in the wrong environment: compare what this Python has installed with the
# versions the project's uv.lock records. Run it first, or paste it into the first cell of a notebook.
import importlib.metadata
import platform
import sys
import tomllib
with open("uv.lock", "rb") as f:
lock = tomllib.load(f)
locked = {p["name"]: p["version"] for p in lock["package"]}
project = next(p for p in lock["package"] if p["source"].get("virtual") == ".")
needed = sorted(dep["name"] for dep in project.get("dependencies", []))
python = platform.python_version()
minimum = lock["requires-python"].removeprefix(">=")
too_old = tuple(map(int, python.split("."))) < tuple(map(int, minimum.split(".")))
print(f"Python {python} (the lock needs {minimum} or newer){': too old' if too_old else ''}")
problems = 1 if too_old else 0
for name in needed:
try:
installed = importlib.metadata.version(name)
except importlib.metadata.PackageNotFoundError:
installed = None
if installed == locked[name]:
print(f" {name:<11} {locked[name]:<8} ok")
else:
problems += 1
print(f" {name:<11} {locked[name]:<8} {'not installed' if installed is None else 'but ' + installed + ' is installed'}")
if problems:
sys.exit(f"{problems} problem(s): stop here and run `uv sync --locked` in this project")
print(f"All {len(needed)} packages match uv.lock.") Output
Python 3.14.8 (the lock needs 3.14.8 or newer) duckdb 1.5.6 ok numpy 2.5.3 ok pandas 3.0.6 ok polars 1.44.2 ok pyarrow 25.0.1 ok All 5 packages match uv.lock.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_env.py
env/pyproject.toml
[project]
name = "sales-analysis"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14.8"
dependencies = [
"duckdb==1.5.6",
"numpy==2.5.3",
"pandas==3.0.6",
"polars==1.44.2",
"pyarrow==25.0.1",
] env/uv.lock
version = 1
revision = 5
requires-python = ">=3.14.8"
resolution-markers = [
"sys_platform == 'win32'",
"sys_platform == 'emscripten'",
"sys_platform != 'emscripten' and sys_platform != 'win32'",
]
[[package]]
name = "duckdb"
version = "1.5.6"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "numpy"
version = "2.5.3"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "pandas"
version = "3.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "python-dateutil" },
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
]
[[package]]
name = "polars"
version = "1.44.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "polars-runtime-32" },
]
[[package]]
name = "polars-runtime-32"
version = "1.44.2"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "pyarrow"
version = "25.0.1"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
[[package]]
name = "sales-analysis"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "duckdb" },
{ name = "numpy" },
{ name = "pandas" },
{ name = "polars" },
{ name = "pyarrow" },
]
[package.metadata]
requires-dist = [
{ name = "duckdb", specifier = "==1.5.6" },
{ name = "numpy", specifier = "==2.5.3" },
{ name = "pandas", specifier = "==3.0.6" },
{ name = "polars", specifier = "==1.44.2" },
{ name = "pyarrow", specifier = "==25.0.1" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "tzdata"
version = "2026.5"
source = { registry = "https://pypi.org/simple" } Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Version note
Run in your browser, the check reports six problems and stops. Pyodide 314.0.7 contains CPython 3.14.2, older than the 3.14.8 the lock asks for, and none of the five packages is installed there. That is the check doing its job: this runner cannot run the project’s pandas, polars or DuckDB code, and the check says so before anything else runs.
In your browser, Pyodide 314.0.7 (CPython 3.14.2) prints (exit status 1):
Python 3.14.2 (the lock needs 3.14.8 or newer): too old duckdb 1.5.6 not installed numpy 2.5.3 not installed pandas 3.0.6 not installed polars 1.44.2 not installed pyarrow 25.0.1 not installed
Printed as an error (standard error):
6 problem(s): stop here and run `uv sync --locked` in this project
The same program in a colleague’s project, whose lock still pins pandas 2.3.3 and adds Matplotlib, run with this track’s environment:
older-project/check_env.py
# Stop an analysis before it runs in the wrong environment: compare what this Python has installed with the
# versions the project's uv.lock records. Run it first, or paste it into the first cell of a notebook.
import importlib.metadata
import platform
import sys
import tomllib
with open("uv.lock", "rb") as f:
lock = tomllib.load(f)
locked = {p["name"]: p["version"] for p in lock["package"]}
project = next(p for p in lock["package"] if p["source"].get("virtual") == ".")
needed = sorted(dep["name"] for dep in project.get("dependencies", []))
python = platform.python_version()
minimum = lock["requires-python"].removeprefix(">=")
too_old = tuple(map(int, python.split("."))) < tuple(map(int, minimum.split(".")))
print(f"Python {python} (the lock needs {minimum} or newer){': too old' if too_old else ''}")
problems = 1 if too_old else 0
for name in needed:
try:
installed = importlib.metadata.version(name)
except importlib.metadata.PackageNotFoundError:
installed = None
if installed == locked[name]:
print(f" {name:<11} {locked[name]:<8} ok")
else:
problems += 1
print(f" {name:<11} {locked[name]:<8} {'not installed' if installed is None else 'but ' + installed + ' is installed'}")
if problems:
sys.exit(f"{problems} problem(s): stop here and run `uv sync --locked` in this project")
print(f"All {len(needed)} packages match uv.lock.") Output (exit status 1)
Python 3.14.8 (the lock needs 3.14.8 or newer) matplotlib 3.11.2 not installed pandas 2.3.3 but 3.0.6 is installed
Printed as an error (standard error)
2 problem(s): stop here and run `uv sync --locked` in this project
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_env.py
older-project/pyproject.toml
[project]
name = "quarterly-report"
version = "0.1.0"
requires-python = ">=3.14.8"
dependencies = [
"matplotlib==3.11.2",
"pandas==2.3.3",
] older-project/uv.lock
version = 1
revision = 5
requires-python = ">=3.14.8"
[[package]]
name = "contourpy"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
[[package]]
name = "cycler"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "fonttools"
version = "4.66.1"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "kiwisolver"
version = "1.5.1"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "matplotlib"
version = "3.11.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "contourpy" },
{ name = "cycler" },
{ name = "fonttools" },
{ name = "kiwisolver" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pillow" },
{ name = "pyparsing" },
{ name = "python-dateutil" },
]
[[package]]
name = "numpy"
version = "2.5.4"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "pandas"
version = "2.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "python-dateutil" },
{ name = "pytz" },
{ name = "tzdata" },
]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "pyparsing"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
[[package]]
name = "pytz"
version = "2026.5"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "quarterly-report"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "matplotlib" },
{ name = "pandas" },
]
[package.metadata]
requires-dist = [
{ name = "matplotlib", specifier = "==3.11.2" },
{ name = "pandas", specifier = "==2.3.3" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "tzdata"
version = "2026.5"
source = { registry = "https://pypi.org/simple" } Two problems, both caught before a single number was computed: Matplotlib is missing, and pandas is 3.0.6 where the
project expects 2.3.3. Running that colleague’s report here would at best crash and at worst print different numbers
without a word of warning. uv sync --locked inside their project folder builds the environment their lock
describes, next to this one.
Scripts, Jupyter notebooks or marimo
The code of an analysis can live in three kinds of file, and each suits a different stage of the work.
A script is a plain .py file that runs from top to bottom every time. It is the easiest form to review, test,
schedule and rerun, and it never holds state from an earlier run, but its tables and charts are not shown next to
the code.
A Jupyter notebook is a .ipynb file: JSON that holds the cells, the output each cell printed and a count of the
order in which they ran (notebook format). The
cells run in a kernel that keeps every variable while the notebook is open, so you can run them in any order. That
makes exploring quick and leaves the classic trap: the output on the page may come from cells run in another order,
or from a cell that has since been edited or deleted. Before you trust or share a notebook, use JupyterLab’s
“Restart Kernel and Run All Cells”
(commands reference) and check that the numbers
do not change.
A marimo notebook is stored as a plain Python file and can also run as a script. When you run a cell, marimo
runs every cell that uses the variables it defines, and deleting a cell deletes its variables, so the page cannot
quietly disagree with the code (running cells). One gap remains:
marimo does not track changes made inside an object, such as rows.append(x) in another cell, so define and change
a variable in the same cell.
| Criterion | Script (.py) | Jupyter (.ipynb) | marimo (.py) |
|---|---|---|---|
| Runs in a fixed order | Yes, top to bottom | No: any order, state kept between runs | Yes, by the order of dependencies |
| Shows results next to the code | No | Yes, saved in the file | Yes, while open; exported on request |
| Reads well in a code review (diff) | Yes | Poorly: JSON with outputs inside | Yes |
| Runs unattended, on a schedule | Yes | With extra tools | Yes, as a script |
| When to choose | Reports and pipelines that run again and again | Exploring, and sharing results with their outputs | Exploring with code that stays consistent and reviewable |
Many analyses use two of them: a notebook while exploring, then a script once the steps are settled and the result has to be produced again every week. Whichever you use, the check from the section above goes first.
Where this track runs its examples
The output under each example is what it printed when it ran with Python 3.14.8 and the versions in the lock file above. A Run button runs an example in your browser with Pyodide 314.0.7, which contains CPython 3.14.2 and the standard library, so it appears only on examples that need nothing else.
Exercise
Exercise · Easy · Python
Report what differs from the lock file
The lesson's check_env.py compares the versions in uv.lock with the versions this Python has installed. Write the comparison as a function you can test: problems(locked, installed) in problems.py.
Both arguments map package names to versions, such as {"pandas": "3.0.6"}. A package that is not installed is missing from installed. Return one message for every locked package that is not installed at its locked version, sorted by package name, in exactly these forms:
"pandas: locked 3.0.6, installed 2.3.3"when another version is installed;"polars: locked 1.44.2, not installed"when it is missing.
Packages that are installed but not in the lock do not matter here. When everything matches, return an empty list.
Starter code · problems.py
def problems(locked, installed):
"""Messages for the locked packages whose installed version differs, sorted by package name.
locked and installed map package names to versions, such as {"pandas": "3.0.6"};
a package that is not installed is missing from installed.
"""
# Replace this line with your code.
return [] The sample tests · test_problems.py
from problems import problems
LOCKED = {"duckdb": "1.5.6", "numpy": "2.5.3", "pandas": "3.0.6", "polars": "1.44.2", "pyarrow": "25.0.1"}
def test_everything_matches():
"""returns an empty list when every locked version is installed"""
assert problems(LOCKED, dict(LOCKED)) == []
def test_other_version_installed():
"""reports a package installed at another version"""
installed = dict(LOCKED, pandas="2.3.3")
assert problems(LOCKED, installed) == ["pandas: locked 3.0.6, installed 2.3.3"]
def test_missing_package():
"""reports a package that is not installed"""
installed = {k: v for k, v in LOCKED.items() if k != "polars"}
assert problems(LOCKED, installed) == ["polars: locked 1.44.2, not installed"]
def test_sorted_and_extras_ignored():
"""lists problems by package name and ignores packages the lock does not name"""
installed = {"numpy": "2.5.3", "duckdb": "1.4.0", "pandas": "3.0.6", "polars": "1.44.2", "rich": "15.0.0"}
assert problems(LOCKED, installed) == ["duckdb: locked 1.5.6, installed 1.4.0", "pyarrow: locked 25.0.1, not installed"] A hint
Walk through sorted(locked), so the messages come out in name order. For each name, installed.get(name) gives the installed version, or None when the package is missing; compare it with locked[name] and append the message that fits. An f-string such as f"{name}: locked {locked[name]}, not installed" builds the text.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What is a lock file, and how is it different from the dependency list in pyproject.toml?
The dependency list in pyproject.toml says what the project needs, often as ranges such as pandas>=3.0, and
names only the packages you chose. A lock file such as uv.lock records the result of resolving that list: the
exact version of every package that gets installed, the dependencies of your dependencies included, with the source
and the hash of each file. Two computers that install from the same lock get the same environment, while two
installs from pyproject.toml alone can pick different versions on different days. You edit pyproject.toml, by
hand or with uv add; the tool writes the lock, and you commit both. uv sync --locked installs exactly what the
lock says and fails when the lock no longer matches pyproject.toml.
Key takeaways
- The environment, meaning the interpreter and the exact library versions, is part of an analysis: the same code can print different numbers in another one.
- Give each analysis its own uv project:
uv init,uv addwith exact versions,uv run, anduv sync --lockedon every other computer. - Commit
pyproject.toml,uv.lockand.python-version; never.venv. - Check the installed versions against the lock before the analysis runs, and stop when they differ.
- Scripts are for runs you repeat, notebooks for exploring; restart and run all cells before trusting a Jupyter notebook.
References
- Built-in functions: sum() (Python Software Foundation)
- What's New In Python 3.12 (Python Software Foundation)
- importlib.metadata, accessing package metadata (Python Software Foundation)
- tomllib, parse TOML files (Python Software Foundation)
- venv, creation of virtual environments (Python Software Foundation)
- Project structure and files: the lockfile (uv documentation) (Astral)
- Locking and syncing (uv documentation) (Astral)
- What's new in 3.0.0 (pandas) (pandas development team (NumFOCUS))
- Migration guide for the new string data type (pandas 3.0) (pandas development team (NumFOCUS))
- The Notebook file format (nbformat) (Project Jupyter)
- JupyterLab commands reference (Project Jupyter)
- marimo documentation (marimo)
- Running cells (marimo user guide) (marimo)
- Pyodide documentation (Pyodide)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress