Data UrchinData Urchin

Help

What the datasets look like, what's actually running when you hit Run, and what your code can and can't do.

The datasets

Most questions pull from five tables belonging to a small (fictional) veterinary practice: owners bring in pets, pets have visits, and each visit is with a vet for a specific problem. The lines below show how the tables join together — a straight line between two PK/FK columns means you can .join(...) on them.

owner_idpet_idvet_idproblem_idownersowner_id · PKfirst_namelast_nameemailcitystatesignup_datepetspet_id · PKowner_id · FKpet_namespeciesbreedsexbirth_dateweight_lbsheight_inlength_invetsvet_id · PKvet_namespecialtyclinicproblemsproblem_id · PKproblem_namecategoryseverityvisitsvisit_id · PKvisit_datepet_id · FKvet_id · FKproblem_id · FKvisit_typecost

It's a simplified simulation, not real Python

Each question loads a small dataset and gives you a variable to work with — either dfor the dataset's real name (e.g. pets). Your code is parsed by a small custom interpreter built for this site, not a real Python or Polars runtime. It understands a specific, narrow chunk of Polars-style syntax — enough to practice the core DataFrame operations — and will give you an error on anything outside that.

A real Python/Polars sandbox (running actual Polars in your browser) is planned as a future upgrade. For now, treat this as a focused practice tool for the operations below rather than a general Python console.

What's supported

Assumed imports

import polars as pl
from polars import col, lit

Every question's code runs as if those two lines already ran — you never write them yourself. That means pl.col("x") and bare col("x") are interchangeable, and so are pl.lit(...) and lit(...) — use whichever style you like.

Preview the data

print(df)
print(pets)

Filter rows

df.filter(pl.col("weight_lbs") > 30)

Select columns

df.select(["pet_name", "weight_lbs"])

Sort rows

df.sort("weight_lbs")
df.sort("weight_lbs", True)  # True = descending

Add a computed column

df.with_columns((pl.col("weight_lbs") * 0.453592).alias("weight_kg"))

Group and aggregate

df.group_by("species").agg(pl.col("weight_lbs").mean())
df.group_by(["species", "sex"]).agg(
  pl.len().alias("count"),
  pl.col("weight_lbs").mean().alias("avg_weight_lbs"),
)

.group_by(...) takes a single column name or a list of column names. .agg(...) takes one or more expressions. Supported aggregations: .mean() .sum() .count() .min() .max() .median() .std() .quantile(p) .n_unique(), plus pl.len()for a row count that doesn't need a column.

Summary statistics without grouping

df.select(
  pl.col("weight_lbs").min().alias("min"),
  pl.col("weight_lbs").max().alias("max"),
)
df.select(pl.col("species").unique())  # distinct values

If every expression passed to .select(...) is one of the aggregations above, the result collapses to a single summary row instead of being grouped. A lone .unique() on a column returns its distinct values instead.

Select, rename, and exclude

df.select("pet_name", pl.col("weight_lbs").alias("swole_size"))
df.select(pl.exclude("owner_id", "birth_date"))
df.rename({"pet_name": "name"})

String methods and regex

pl.col("pet_name").str.contains("ik")
pl.col("pet_name").str.starts_with("Ti")
pl.col("pet_name").str.ends_with("ki")
pl.col("species").str.to_lowercase()
pl.col("species").str.to_uppercase()
pl.col("breed").str.contains("(?i)shepherd")   # case-insensitive regex
pl.col("email").str.extract(r"@(.+)", 1)       # capture group 1
pl.col("signup_date").str.replace_all(r"\d", "X")  # regex replace

.str.contains(...), .str.replace_all(...), and .str.extract(pattern, group) treat their pattern as a real (JS-flavored) regular expression. Prefix a pattern with (?i) for case-insensitive matching. .str.starts_with(...) and .str.ends_with(...) stay literal substring checks.

Dates

pl.col("birth_date").str.to_date().dt.year()
pl.col("birth_date").str.to_date().dt.strftime("%B")

Conditionals and null handling

pl.when(pl.col("weight_lbs") > 30).then("large")
  .when(pl.col("weight_lbs") > 10).then("medium")
  .otherwise("small")
pl.col("cost").fill_null(0)
pl.col("sex").replace({"F": "Female", "M": "Male"})
pl.col("weight_lbs").cast("Int64")  # or "Float64" / "String"

Window functions (.over)

df.with_columns(
  pl.col("weight_lbs").rank(True).over("species").alias("rank"),
)
df.with_columns(
  pl.col("weight_lbs").mean().over("species").alias("species_avg"),
)

.over(...) computes a per-group value (an aggregation, .rank(...), or .cum_count()) and broadcasts it back onto every row, without collapsing the row count. It has to be the entire expression passed to .with_columns(...)— it can't be combined with other arithmetic in the same expression.

Rows and duplicates

df.head(4)
df.tail(7)
df.unique()               # drop duplicate rows
df.with_row_index("row_num")
df["breed"].value_counts()  # index a single column, then count occurrences
df["breed"].n_unique()

Dtypes, schema, and describe

df.dtypes     # (column, dtype) table
df.schema     # one row, one column per field
df.describe() # count / null_count / mean / std / min / max / median

Joins

pets.join(owners, "owner_id", "left")
pets.join(visits, "pet_id", "anti")  # rows in pets with no match in visits

.join(other, "on_column", "how")how is "inner" (default), "left", or "anti". Any dataset can reference any other dataset by name — code isn't limited to the primary dataset the question loaded.

Reshaping: pivot, unpivot, concat

visits.pivot("visit_type", "vet_id", "cost", "sum")
pets.unpivot(["pet_id", "pet_name"], ["weight_lbs", "height_in"], "measurement", "value")
pl.concat([pets.filter(pl.col("species") == "dog"), pets.filter(pl.col("species") == "cat")])
pl.concat([a, b], "horizontal")

The lazy API

pl.scan_csv("pets").filter(pl.col("species") == "dog").select("pet_name").collect()
pl.scan_csv("pets").filter(pl.col("species") == "dog").explain()

This sandbox is always eager under the hood, so .collect() is a no-op here — the syntax matches real Polars even though nothing is deferred. pl.scan_csv(...), pl.read_csv(...), and pl.read_parquet(...) all take a dataset name (with or without a fake file extension) instead of a real path.

Bucketing dates

visits.group_by_dynamic("visit_date", "1mo").agg(pl.len().alias("visit_count"))

Bucket sizes: "1d", "1mo", "1y".

Sampling

df.sample(10, 123)              # 10 rows, seeded
df.sample_frac(0.1, 123)        # 10% of rows, seeded
df.sample(10, 123, True)        # with replacement

Sampling uses this sandbox's own seeded random generator, not real Polars' — the same seed always reproduces the same rows here, which is what grading relies on, but the exact rows won't match a real Polars session with the same seed.

Reading and writing files

pets.write_csv("dogs_only.csv")
pets.write_parquet("pets.parquet")
pl.read_parquet("pets.parquet")

There's no real filesystem in this sandbox — writes are simulated as no-ops that hand back the table as it would have been written, and reads resolve straight back to the matching dataset by name.

Operators and literals

Comparisons: == != > < >= <=. Logical: & (and), | (or), ~ (not) — wrap each side in parentheses, e.g. (pl.col("a") > 1) & (pl.col("b") == "x"). Arithmetic: + - * /. Literals: strings ("text"), numbers, True/False, lists (["a", "b"]), and dictionaries ({"a": 1}).

What's not supported

Where to start

If you're not sure what a dataset looks like, run print(df) first to see all its columns and rows, then build up your answer from there.

← Back to practice questions