Tables and charts
PLL adds tables to Python: you do not need to import anything to use them. A
table shows up in the interactions panel as a card you can scroll, with a
Save CSV button, which saves the whole table. A number is shown as Python
prints it (2.0, 12999.99), and a column of numbers lines up on the right, so a
column of numbers that is still text (from a CSV) stands out by lining up on the
left. Each operation below is shown with its contract (the types of its inputs and
output), followed by some examples and what they produce.
Tables never change: every operation on a table produces a new table, and
leaves the original as it was. Operations on tables are methods, written after
the table with a dot, like workouts.filter(is_long). For type annotations, the
type of a table is Table, and the type of a row is dict.
A table is not a list or a dictionary: its rows and columns are taken out with
its methods, row and column, and a mistake like this one
says which to use:
table(["name"], [["Ada"]])["name"]
TypeError: a table's columns are not taken out with square brackets: write t.column("name") for the values in it.
Creating tables
table
table(columns: list[str], rows: list[list]) -> Table
A table with the given column names, and the given rows, each of which is a list
with one value for each column (in the same order as columns).
workouts = table(
["date", "activity", "duration"],
[
["2025-04-01", "Running", 30],
["2025-04-02", "Yoga", 45],
["2025-04-03", "Cycling", 60],
["2025-04-04", "Running", 25],
["2025-04-05", "Yoga", 50],
],
)
workouts
| date | activity | duration |
|---|---|---|
| 2025-04-01 | Running | 30 |
| 2025-04-02 | Yoga | 45 |
| 2025-04-03 | Cycling | 60 |
| 2025-04-04 | Running | 25 |
| 2025-04-05 | Yoga | 50 |
A row can also be a dict, like the ones t.row(n) gives you, so you
can make a new table from some of the rows of another:
table(workouts.columns(), [workouts.row(0), workouts.row(2)])
| date | activity | duration |
|---|---|---|
| 2025-04-01 | Running | 30 |
| 2025-04-03 | Cycling | 60 |
table_from_columns
table_from_columns(columns: dict) -> Table
A table made from a dictionary from column names to lists of values (one for each row), for when you have the data column by column.
table_from_columns({
"name": ["Ada", "Grace", "Alan"],
"born": [1815, 1906, 1912],
})
| name | born |
|---|---|
| Ada | 1815 |
| Grace | 1906 |
| Alan | 1912 |
load_table
load_table(source: str) -> Table
A table read from a CSV ("comma separated values") file: either a file in the same
folder as your program (when source is a file name, like "voters.csv"), or
one on the web (when source starts with https://). The first line of the CSV
gives the names of the columns.
recipes_raw = load_table("https://raw.githubusercontent.com/neu-pdi/cs2000-public-resources/refs/heads/main/static/support/5-recipes.csv")
recipes_raw.head(3)
| title | servings | prep time |
|---|---|---|
| Classic Pancakes | 4 | 15 |
| Banana Oatmeal Muffins | 12 | 20 |
| Avocado Toast | 2 | 10 |
Every value loaded from a CSV is a string, including the ones that look like
numbers, and an empty cell is the empty string "":
recipes_raw.row(0)
{'title': 'Classic Pancakes', 'servings': '4', 'prep time': '15'}
To convert a column of numbers, use transform_column with
int (for whole numbers) or float:
recipes = recipes_raw.transform_column("servings", int).transform_column("prep time", int)
recipes.row(0)
{'title': 'Classic Pancakes', 'servings': 4, 'prep time': 15}
If some values in a column aren't numbers (e.g., "", or "1,234"), int and
float cause an error that names the row, so you'll need to write a function that
handles those values, and use that with transform_column instead.
A CSV saved by Excel loads either way it can be saved ("CSV UTF-8", or a plain
"CSV"). An address that gives back a web page rather than a CSV, such as a file's
page on GitHub, is said to be one, with the fix (use the address of its Raw
button). A file next to your program can be up to 50 MB. In the browser, an address
on the web only works if its site allows other sites to read it: if
load_table reports that it could not reach an address, download the CSV, add
it to your repository (next to your program), and load it by its file name
instead.
Getting information from tables
columns
t.columns() -> list[str]
The names of the table's columns, in order.
workouts.columns()
['date', 'activity', 'duration']
length
t.length() -> int
The number of rows in the table. (len(t) gives the same thing.)
workouts.length()
5
row
t.row(n: int) -> dict
Row number n of the table, as a dict from column names to values. The first
row is number 0, and the last is one less than the number of rows.
workouts.row(1)
{'date': '2025-04-02', 'activity': 'Yoga', 'duration': 45}
workouts.row(5)
IndexError: there is no row 5: this table's rows are numbered 0 to 4.
To get one value from a row, write the column's name in square brackets after it:
workouts.row(1)["activity"]
'Yoga'
workouts.row(1)["time"]
KeyError: this row has no column "time" (it has: date, activity, duration).
column
t.column(name: str) -> list
All of the values in a column, as a list, in order.
workouts.column("duration")
[30, 45, 60, 25, 50]
rows
t.rows() -> list[dict]
All of the rows, as a list of dicts.
workouts.rows()
[{'date': '2025-04-01', 'activity': 'Running', 'duration': 30},
{'date': '2025-04-02', 'activity': 'Yoga', 'duration': 45},
{'date': '2025-04-03', 'activity': 'Cycling', 'duration': 60},
{'date': '2025-04-04', 'activity': 'Running', 'duration': 25},
{'date': '2025-04-05', 'activity': 'Yoga', 'duration': 50}]
Making new tables
filter
t.filter(keep: function) -> Table
A table with only the rows for which keep(row) returns True. Note that we give
filter the function itself (with no parentheses after its name): filter
calls it on each row.
def is_long(r: dict) -> bool:
"""whether the workout lasted at least 45 minutes"""
return r["duration"] >= 45
workouts.filter(is_long)
| date | activity | duration |
|---|---|---|
| 2025-04-02 | Yoga | 45 |
| 2025-04-03 | Cycling | 60 |
| 2025-04-05 | Yoga | 50 |
workouts.filter(lambda r: r["activity"] == "Yoga")
| date | activity | duration |
|---|---|---|
| 2025-04-02 | Yoga | 45 |
| 2025-04-05 | Yoga | 50 |
transform_column
t.transform_column(name: str, change: function) -> Table
A table where every value v in the column name is replaced by change(v).
workouts.transform_column("duration", lambda minutes: minutes / 60)
| date | activity | duration |
|---|---|---|
| 2025-04-01 | Running | 0.5 |
| 2025-04-02 | Yoga | 0.75 |
| 2025-04-03 | Cycling | 1.0 |
| 2025-04-04 | Running | 0.4166666666666667 |
| 2025-04-05 | Yoga | 0.8333333333333334 |
workouts.transform_column("activity", lambda a: a.upper())
| date | activity | duration |
|---|---|---|
| 2025-04-01 | RUNNING | 30 |
| 2025-04-02 | YOGA | 45 |
| 2025-04-03 | CYCLING | 60 |
| 2025-04-04 | RUNNING | 25 |
| 2025-04-05 | YOGA | 50 |
add_column
t.add_column(name: str, compute: function) -> Table
t.add_column(name: str, values: list) -> Table
A table with a new column, at the right, called name. Its value in each row is
either compute(row), or, if given a list, the values in the list (one for each
row, in order).
def calories(r: dict) -> float:
"""estimated calories burned in the workout"""
return r["duration"] * 8
workouts.add_column("calories", calories)
| date | activity | duration | calories |
|---|---|---|---|
| 2025-04-01 | Running | 30 | 240 |
| 2025-04-02 | Yoga | 45 | 360 |
| 2025-04-03 | Cycling | 60 | 480 |
| 2025-04-04 | Running | 25 | 200 |
| 2025-04-05 | Yoga | 50 | 400 |
workouts.add_column("rating", [4, 5, 3, 4, 5])
| date | activity | duration | rating |
|---|---|---|---|
| 2025-04-01 | Running | 30 | 4 |
| 2025-04-02 | Yoga | 45 | 5 |
| 2025-04-03 | Cycling | 60 | 3 |
| 2025-04-04 | Running | 25 | 4 |
| 2025-04-05 | Yoga | 50 | 5 |
add_row
t.add_row(row: list | dict) -> Table
A table with one more row, at the end: either a list with one value for each
column, in order, or a dict from column names to values.
workouts.add_row(["2025-04-06", "Swimming", 40])
| date | activity | duration |
|---|---|---|
| 2025-04-01 | Running | 30 |
| 2025-04-02 | Yoga | 45 |
| 2025-04-03 | Cycling | 60 |
| 2025-04-04 | Running | 25 |
| 2025-04-05 | Yoga | 50 |
| 2025-04-06 | Swimming | 40 |
workouts.add_row({"date": "2025-04-06", "activity": "Swimming", "duration": 40})
| date | activity | duration |
|---|---|---|
| 2025-04-01 | Running | 30 |
| 2025-04-02 | Yoga | 45 |
| 2025-04-03 | Cycling | 60 |
| 2025-04-04 | Running | 25 |
| 2025-04-05 | Yoga | 50 |
| 2025-04-06 | Swimming | 40 |
select_columns
t.select_columns(names: list[str]) -> Table
A table with only the columns in names, in that order.
workouts.select_columns(["activity", "date"])
| activity | date |
|---|---|
| Running | 2025-04-01 |
| Yoga | 2025-04-02 |
| Cycling | 2025-04-03 |
| Running | 2025-04-04 |
| Yoga | 2025-04-05 |
order_by
t.order_by(name: str, ascending: bool = True) -> Table
A table with the same rows, sorted by the column name: smallest first if
ascending is True (or left out), and largest first if it is False. Strings
are sorted in dictionary order (with all uppercase letters before all lowercase
ones).
workouts.order_by("duration", ascending=True)
| date | activity | duration |
|---|---|---|
| 2025-04-04 | Running | 25 |
| 2025-04-01 | Running | 30 |
| 2025-04-02 | Yoga | 45 |
| 2025-04-05 | Yoga | 50 |
| 2025-04-03 | Cycling | 60 |
workouts.order_by("activity", ascending=False)
| date | activity | duration |
|---|---|---|
| 2025-04-02 | Yoga | 45 |
| 2025-04-05 | Yoga | 50 |
| 2025-04-01 | Running | 30 |
| 2025-04-04 | Running | 25 |
| 2025-04-03 | Cycling | 60 |
head and tail
t.head(n: int = 10) -> Table
t.tail(n: int = 10) -> Table
A table of just the first (head) or last (tail) n rows: 10, if n is left
out.
workouts.head(2)
| date | activity | duration |
|---|---|---|
| 2025-04-01 | Running | 30 |
| 2025-04-02 | Yoga | 45 |
workouts.tail(2)
| date | activity | duration |
|---|---|---|
| 2025-04-04 | Running | 25 |
| 2025-04-05 | Yoga | 50 |
Summarizing columns
sum, mean, min, and max
t.sum(name: str) -> int | float
t.mean(name: str) -> float
t.min(name: str)
t.max(name: str)
The total, average, smallest value, and largest value of a column. sum and
mean need a column of numbers (the sum of whole numbers is a whole number);
min and max also work on strings (dictionary order). At #level beginner and
#level intermediate, True and False are not numbers to a table: sum,
mean and the charts refuse them.
min, max and order_by refuse a column of numbers that is still
text, when comparing it as text would give a different answer (as text, "9"
comes after "100"), and say to convert it first:
recipes_raw.max("servings")
TypeError: max compares column "servings" as text, and as text "12" comes before "2": the column holds numbers written as text, like those in quotes or read from a CSV. Convert the column first: transform_column("servings", int).
workouts.sum("duration")
210
workouts.mean("duration")
42.0
workouts.min("duration")
25
workouts.max("activity")
'Yoga'
For anything else, like the median or the standard deviation, use a column (a
list) with Python's own statistics
library:
import statistics
statistics.median(workouts.column("duration"))
45
statistics.stdev(workouts.column("duration"))
14.404860290887934
Comparing tables
Two tables are == when they have the same columns, in the same order, holding
the same values, in the same order:
workouts.head(2) == table(
["date", "activity", "duration"],
[
["2025-04-01", "Running", 30],
["2025-04-02", "Yoga", 45],
],
)
True
workouts.head(2) == workouts.tail(2)
False
So you can test a function that produces a table by comparing its result with the table you expect:
def long_workouts(t: Table) -> Table:
"""the workouts of at least 45 minutes"""
return t.filter(is_long)
def test_long_workouts():
assert long_workouts(workouts.head(3)) == table(
["date", "activity", "duration"],
[
["2025-04-02", "Yoga", 45],
["2025-04-03", "Cycling", 60],
],
)
When a test like this fails, PLL says where the two tables differ: the first row that does, or the columns, or the number of rows.
Charts
Charts are made by methods on tables, and produce images, which
show up in the interactions panel. Since a chart is an image, beside, above
and the rest work on it, and a function that returns one is annotated -> Image.
Every chart can also be given a title=, and needs a table with at least one
row. The examples use this table:
study = table(
["name", "year", "hours", "score"],
[
["Avery", "first", 2, 62],
["Blake", "second", 4, 71],
["Casey", "first", 5, 74],
["Devon", "third", 7, 80],
["Emery", "second", 8, 85],
["Finley", "first", 3, 68],
["Gray", "third", 10, 91],
["Harper", "second", 6, 79],
],
)
study
| name | year | hours | score |
|---|---|---|---|
| Avery | first | 2 | 62 |
| Blake | second | 4 | 71 |
| Casey | first | 5 | 74 |
| Devon | third | 7 | 80 |
| Emery | second | 8 | 85 |
| Finley | first | 3 | 68 |
| Gray | third | 10 | 91 |
| Harper | second | 6 | 79 |
bar_chart
t.bar_chart(labels: str, values: str) -> Image
One bar per row, labeled with the row's value in the labels column, and as tall
as its value in the values column.
study.bar_chart("name", "score")
freq_bar_chart
t.freq_bar_chart(name: str) -> Image
One bar for each different value in the column name, as tall as the number of
rows that have that value.
study.freq_bar_chart("year")
pie_chart
t.pie_chart(labels: str, values: str) -> Image
One slice per row, labeled with the row's value in the labels column, and sized
by its share of the total of the values column.
study.pie_chart("name", "hours", title="Hours studied")
histogram
t.histogram(name: str, bins: int = 10, bin_width: float = None) -> Image
How many rows have values in each range (or bin) of the column name. Give it
bins= to choose how many bins there are, or bin_width= to choose how wide
each one is (a histogram can have at most 1,000 bins).
study.histogram("score", bin_width=5)
study.histogram("score", bins=3)
scatter_plot
t.scatter_plot(x: str, y: str) -> Image
One point per row, at its values in the x and y columns.
study.scatter_plot("hours", "score")
line_chart
t.line_chart(x: str, y: str) -> Image
The same points as scatter_plot, joined by lines, in order of x.
study.line_chart("hours", "score")
dot_plot
t.dot_plot(name: str) -> Image
One dot per row, along the values of the column name, stacked where rows share
a value.
study.dot_plot("hours")
box_plot
t.box_plot(name: str) -> Image
A box and whisker plot of the column name: its quartiles, range, and any
outliers.
study.box_plot("score")
lr_plot
t.lr_plot(x: str, y: str) -> Image
A scatter plot, with the line that best fits the points (a linear regression), and how well it fits (r²) in the title.
study.lr_plot("hours", "score")
Labeled plots
t.labeled_scatter_plot(labels: str, x: str, y: str) -> Image
t.labeled_dot_plot(labels: str, name: str) -> Image
t.labeled_lr_plot(labels: str, x: str, y: str) -> Image
Like scatter_plot, dot_plot, and lr_plot, but with each point colored by
its value in the labels column, with a key.
study.labeled_scatter_plot("year", "hours", "score")
linear_regression
t.linear_regression(x: str, y: str) -> tuple[float, float, float]
The line of best fit (the one lr_plot draws) as numbers instead of a picture: a
tuple of its slope, its intercept, and r².
study.linear_regression("hours", "score")
(3.5037593984962405, 56.54135338345865, 0.9883485954595933)
function_plot
function_plot(f: function, x_min: float, x_max: float) -> Image
A plot of the function f, for x from x_min to x_max. (It isn't a method:
it doesn't need a table.)
function_plot(lambda x: x * x, -3, 3)
Using pandas
Later in the semester, we'll learn pandas, a widely
used Python library for working with tables. t.to_pandas() turns a PLL table
into a pandas DataFrame:
workouts.to_pandas()
date activity duration
0 2025-04-01 Running 30
1 2025-04-02 Yoga 45
2 2025-04-03 Cycling 60
3 2025-04-04 Running 25
4 2025-04-05 Yoga 50
Charts with matplotlib
matplotlib, the most widely used Python library for
charts, works too: plt.show() shows each figure in the interactions panel, as a
picture.
import matplotlib.pyplot as plt
plt.plot([1, 2, 3, 4], [1, 4, 9, 16])
plt.title("Squares")
plt.show()
Any other package that Pyodide (the Python PLL runs on) has can be imported too: numpy, scipy, requests, and many more. The first time, it is downloaded, which needs the network.