STAT163 — Data Manipulation Essentials
2026-10-12
members: the gym's member list
| member_id | name | plan | status |
|---|---|---|---|
| M-01 | O. Symonenko | pro | active |
| M-02 | I. Danylko | basic | active |
| M-03 | K. Fedorak | pro | frozen |
| M-04 | Y. Herasym | basic | active |
| M-05 | L. Kostiuk | day | active |
| M-06 | P. Zinchuk | pro | cancelled |
| M-07 | H. Melnychuk | basic | active |
| M-08 | R. Oliynyk | pro | active |
visits: the entrance gate log, June
| visit_id | member_id | date | minutes |
|---|---|---|---|
| V-901 | M-01 | 2026-06-01 | 75 |
| V-902 | M-03 | 2026-06-01 | 40 |
| V-903 | M-01 | 2026-06-03 | 80 |
| V-904 | M-05 | 2026-06-03 | 55 |
| V-905 | M-77 | 2026-06-04 | 60 |
| V-906 | M-02 | 2026-06-05 | 35 |
| V-907 | M-01 | 2026-06-08 | 70 |
| V-908 | M-05 | 2026-06-09 | 50 |
| V-909 | M-02 | 2026-06-10 | 45 |
| V-910 | M-08 | 2026-06-11 | 90 |
Your answer
How long is a visit on each plan, on average? What do you need from each table to answer it?
member_idEach row of the result is a visit with its member's plan added. Grouped by plan, all June visits average 71 minutes on pro, 40 on basic and 52.5 on day.
Your answers
For each question: which rows must you keep in the result, and which rows can you lose?
Choose how= before you write the join, by asking which rows you must not lose. With inner you get matched rows only, with left or right every row of one table, and with outer every row of both.
In a database, most data is stored in two kinds of tables. A fact table has one row per event: a visit, a payment, an order. A lookup table has one row per thing the events refer to: a member, a product, a shop.
A join is safe when every key in the fact table is in the lookup table exactly once.
The average is 9 / 5 = 1.8 visits per member in June.
Your guess
The gym has 8 members. Is 1.8 the average number of visits per member?
| member_id | visits | _merge |
|---|---|---|
| M-01 | 3 | both |
| M-02 | 2 | both |
| M-03 | 1 | both |
| M-04 | NaN | left_only |
| M-05 | 2 | both |
| M-06 | NaN | left_only |
| M-07 | NaN | left_only |
| M-08 | 1 | both |
With how="left" from members, the three members who never came stay. With 0 visits for them, the average is 9 / 8 = 1.125.
members
| member_id | name |
|---|---|
| M-01 | O. Symonenko |
| M-02 | I. Danylko |
| M-03 | K. Fedorak |
| M-04 | Y. Herasym |
| M-05 | L. Kostiuk |
| M-06 | P. Zinchuk |
| M-07 | H. Melnychuk |
| M-08 | R. Oliynyk |
visits
| visit_id | member_id | minutes |
|---|---|---|
| V-901 | M-01 | 75 |
| V-902 | M-03 | 40 |
| V-903 | M-01 | 80 |
| V-904 | M-05 | 55 |
| V-905 | M-77 | 60 |
| V-906 | M-02 | 35 |
| V-907 | M-01 | 70 |
| V-908 | M-05 | 50 |
| V-909 | M-02 | 45 |
| V-910 | M-08 | 90 |
Your ideas
V-905 belongs to M-77, who is not in the member list. Where could this row come from? What would you do with it?
A typo at the front desk, a deleted account, a test record from the gate. Find the rows and look at them first: whether you delete, fix or keep them depends on the cause.
Somebody exported the member list again, with a filter: active members only.
members: the filter removes the two red rows
| member_id | plan | status |
|---|---|---|
| M-01 | pro | active |
| M-02 | basic | active |
| M-03 | pro | frozen |
| M-04 | basic | active |
| M-05 | day | active |
| M-06 | pro | cancelled |
| M-07 | basic | active |
| M-08 | pro | active |
visits of pro members
| visit_id | member_id | minutes |
|---|---|---|
| V-901 | M-01 | 75 |
| V-902 | M-03 | 40 |
| V-903 | M-01 | 80 |
| V-907 | M-01 | 70 |
| V-910 | M-08 | 90 |
average visit, pro plan
| member list | minutes |
|---|---|
| all members | 71 |
| active members | 78.75 |
Discuss
Which visits dropped out of the join? Should they count in the average?
V-902, the 40-minute visit of M-03, whose membership is frozen. The answer depends on the question: time spent in the gym, or time spent by paying members.
In the analyst's report, June revenue is 7,257.20 €. In the finance department's report, it is 3,571.80 €, about half.
orders: one row per order
| order_id | placed | order_total |
|---|---|---|
| O-501 | 06-01 | 611.00 |
| O-502 | 06-03 | 129.90 |
| O-503 | 06-05 | 1264.00 |
| O-504 | 06-08 | 89.50 |
| O-505 | 06-10 | 432.00 |
| O-506 | 06-14 | 55.00 |
| O-507 | 06-17 | 780.40 |
| O-508 | 06-21 | 210.00 |
the analyst's code
Your task
The report has two errors. Find them.
shipments: one row per parcel
| shipment_id | order_id | parcel | carrier |
|---|---|---|---|
| S-701 | O-501 | 1/1 | DHL |
| S-702 | O-502 | 1/1 | NovaPost |
| S-703 | O-503 | 1/3 | DHL |
| S-704 | O-503 | 2/3 | DHL |
| S-705 | O-503 | 3/3 | UPS |
| S-706 | O-504 | 1/1 | NovaPost |
| S-707 | O-505 | 1/2 | UPS |
| S-708 | O-505 | 2/2 | UPS |
| S-709 | O-507 | 1/2 | DHL |
| S-710 | O-507 | 2/2 | DHL |
| S-711 | O-508 | 1/1 | NovaPost |
The code runs and the table looks normal. You see the two errors only in the totals. Rows repeated by a join like this are called fan-out: when the row count grows, the sums grow with it.
The member list now has a row for every change of plan or status. M-02 moved from basic to pro on 7 June, and M-03 came back from a freeze on 28 May.
members: one row per change
| member_id | plan | status | since |
|---|---|---|---|
| M-01 | pro | active | 2026-01-12 |
| M-02 | basic | active | 2026-02-03 |
| M-02 | pro | active | 2026-06-07 |
| M-03 | pro | frozen | 2026-04-15 |
| M-03 | pro | active | 2026-05-28 |
| M-04 | basic | active | 2026-03-05 |
| M-05 | day | active | 2026-04-22 |
| M-06 | pro | cancelled | 2026-05-01 |
| M-07 | basic | active | 2026-05-14 |
| M-08 | pro | active | 2026-06-02 |
visits joined with members: M-02 and M-03
| visit_id | date | member_id | plan | status |
|---|---|---|---|---|
| V-902 | 06-01 | M-03 | pro | frozen |
| V-902 | 06-01 | M-03 | pro | active |
| V-906 | 06-05 | M-02 | basic | active |
| V-906 | 06-05 | M-02 | pro | active |
| V-909 | 06-10 | M-02 | basic | active |
| V-909 | 06-10 | M-02 | pro | active |
9 visits go into the join, and 12 rows come out.
Discuss
Nothing in the member list is wrong. What does one of its rows stand for now?
The grain of the member list changed: one row is now a member from a date on. We wrote the join as if there were one row per member. For each visit we need the row that was valid on its date: V-902 active, V-906 basic, V-909 pro.
A company pays for its employees' gym membership. Its HR office sends the gym a list of names, and the gym's manager has to find which employees are members.
members: from the gym
| member_id | name | plan |
|---|---|---|
| M-11 | Oksana Kovalenko | pro |
| M-12 | Oleksiy Bondar | basic |
| M-13 | Ivan Melnyk | pro |
| M-14 | Iryna Savchuk | day |
employees: from the company
| name | department |
|---|---|
| Kovalenko Oksana | Finance |
| Oleksii Bondar | IT |
| Ivan Melnyk | Sales |
| Ivan Melnyk | Logistics |
| Iryna Savchuk | HR |
Your answer
Can you join these lists on name? What goes wrong?
Oksana Kovalenko and Kovalenko Oksana are one person, with the names in a different order. Oleksiy and Oleksii are two spellings of one name. If you join on name, both pairs are missed.Ivan Melnyk. If you join on name, member M-13 gets both of them.That is why each person gets an identifier: a tax number, an employee number, member_id. It has no meaning, so a new name or address does not change it. A name and an address are attributes that come with the key.
Data: the gym, the company, the shop and their tables are made up for this lecture.
McKinney, Python for Data Analysis, 3rd ed. — Ch. 8.2. McGregor, Practical Python Data Wrangling and Data Quality — Ch. 6–7.

Kyiv School of Economics