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.
owners— 300 rows. People who own pets.pets— 500 rows. Each pet belongs to one owner.vets— 5 rows. The veterinarians on staff.problems— 40 rows. A lookup table of medical issues a visit can be for.visits— 1,000 rows. One row per vet visit, tying together a pet, a vet, and a problem, plus a visit_type and cost.
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, litEvery 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 = descendingAdd 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 valuesIf 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 / medianJoins
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 replacementSampling 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
- Variables, assignment, loops, or conditionals
- Writing your own
importstatements —pl,col, andlitare already available without importing them (see above) - F-strings or string formatting
- Multiple statements or lines — one expression only
- Comments
- Any function besides
print(...) - Constructing a brand-new dataframe from scratch (e.g.
pl.DataFrame({...})) — you can only work with the datasets this site already loads - Keyword arguments — every method takes positional arguments only
- Methods other than the ones listed above
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.