# Example: clean a CSV
URL: https://lsd.tools/docs/ex-clean-csv
Updated: 2026-07-18

> A complete tool.py with three nodes — a load step that reads a file the user picks, a shape step that renames/filters/sorts, and a deliver step that logs a null report and writes a clean CSV. Wire it and run it from Python with an Engine, or drop it in a project folder and use the desktop.

The everyday job: read a messy CSV, tidy it, and write a clean one. This is a full package — a
[load](/docs/tools-load), a [shape](/docs/tools-shape), and a [deliver](/docs/tools-deliver) step —
that you can run headless or drop into a project folder.

## The code

```python
# tool.py
from lsdtools import Tool, Source, Table

tool = Tool("csvclean", label="CSV Clean")


@tool.load(source_kinds=("file",))
def load_people(src: Source) -> Table:
    """Read whatever CSV/Parquet file the user picks."""
    return src.read()


@tool.shape
def clean(t: Table, min_age: int = 0) -> Table:
    """Rename messy headers, drop rows under `min_age`, then sort by name."""
    t = t.rename({"full_name": "name", "yrs": "age"})
    t = t.filter(t["age"] >= min_age)
    return t.sort("name")


@tool.deliver
def write_clean(t: Table, path: str = "clean.csv") -> None:
    """Log a per-column null report, then write the cleaned table to CSV."""
    t.null_report().print()
    t.write_csv(path)
```

## What each line does

- **`@tool.load(source_kinds=("file",))`** — a node that produces data. `src: Source` is an
 **injected reader** bound to the file the user picks; `src.read()` returns a
 [`Table`](/docs/api-table). Narrowing `source_kinds` to `"file"` keeps the picker to files.
- **`@tool.shape`** — a transform. `t: Table` is an **input port** (wire it from the load node);
 `min_age: int = 0` has a default, so it is an optional **parameter** and renders as a number field.
- **`t.rename({"full_name": "name", ...})`** — rename columns with an `{old: new}` map; unlisted
 columns pass through.
- **`t.filter(t["age"] >= min_age)`** — `t["age"] >= min_age` is a boolean [`Column`](/docs/api-table);
 `filter` keeps the rows where it is `True`.
- **`@tool.deliver`** — a sink. `null_report()` returns a small `column | nulls | pct` table;
 `.print()` logs it and returns the table. `write_csv(path)` writes the file.

## Run it in Python

No desktop needed — build a sample file, wire the three steps, and run with an
[`Engine`](/docs/api-engine):

```python
from lsdtools import Engine, Source, Table

# make a sample CSV to read
Table({
        "full_name": ["Zoe", "Ada", "Kai"],
        "yrs": [17, 34, 22],
}).write_csv("people.csv")

src = load_people(Source.file("people.csv"))
Engine().run(write_clean(clean(src, min_age=18), path="clean.csv"))
# → null report prints; clean.csv now holds only Ada and Kai (Zoe is under 18), sorted by name

Table.read_csv("clean.csv").print()
# → name age
# Ada 34
# Kai 22
```

`clean(src, min_age=18)` wires the shape's port to the load node `src`; `write_clean(...)` wires the
deliver to the shape. `Engine().run(...)` executes and caches the chain.

## Try changing it

- Add a second deliver that keeps a Parquet copy: `t.write_parquet("clean.parquet")`.
- Drop rows with any null instead of a fixed age — filter on `t["age"].is_valid()`.
- Add a control [view](/docs/ui-panels) — a `View` with a slider for `min_age` and a Run button.

## Related

**Learn** — [Load steps](/docs/tools-load) · [Shape steps](/docs/tools-shape) · [Deliver steps](/docs/tools-deliver)

**API** — [Table](/docs/api-table) · [Source](/docs/api-source) · [Tool](/docs/api-tool)

**Examples** — [File loader](/docs/ex-file-loader) · [Live node metrics](/docs/ex-graph-outputs)
