Skip to main content
Learning Center
Python Programming

Module 8: Data Analysis

Thinking in Rows and Columns

The mental model behind every table tool, the select-filter-sort-limit sequence, and how the same four operations look in plain Python and in a DataFrame.

Lesson 25 of 46 in the recommended order · About 25 min (estimate)

On this page

Outcome

By the end of this lesson you can perform the four operations that make up most data work, in the right order, on a batch of records, and you can read a DataFrame expression and say what it does even though you are writing plain Python.

Why it matters

The shortlist is now a table: rows of opportunities, columns of fields. Almost every question a reviewer asks about it is some combination of four operations, and the answers are wrong in predictable ways when the operations happen in the wrong order.

There is a practical note about tooling. Pandas is the standard Python library for this work, and any local project you build should use it. It is not available in this page's Python runtime, which ships the standard library only. That is a deliberate trade: a small, fast, offline runtime for short exercises instead of a large download on a phone. So the examples here are plain Python, and each one is paired with the DataFrame expression it corresponds to, which is what you would write on your own machine. The reasoning transfers exactly; only the notation differs.

Concept

Four operations, ordered so each step still has the data it needs:

  1. Select the columns you need. Keep any columns still needed for filtering or sorting, or make the reduced-column view a separate branch from the original rows.
  2. Filter the rows you care about. Do this before sorting: sorting rows you are about to discard is wasted work, and on a large table it is the expensive step.
  3. Sort by the column that expresses "most interesting first".
  4. Limit to the top few.

In plain Python those are a comprehension that builds smaller dictionaries, a comprehension with a condition, sorted(rows, key=..., reverse=True), and a slice.

sorted takes a key function that returns the value to order by, and it returns a new list. The list method rows.sort() rearranges in place and returns None, which is Module 3's in-place-versus-new distinction appearing again. Prefer sorted when you will want the original order for a different question later, which is more often than you expect.

The DataFrame vocabulary, for reading code you will meet elsewhere:

  • A DataFrame is the whole table; a Series is one column.
  • df[["notice_id", "amount"]] selects columns; the comprehension equivalent builds smaller dictionaries.
  • df[df["state"] == "OR"] filters rows. The inner part produces a column of True/False, and the outer part keeps the rows where it is True. That is the piece that looks strange at first and is worth naming: it is a boolean mask, not a comparison to a string.
  • df.sort_values("amount", ascending=False) sorts; df.head(2) limits.

Two habits that survive the change of tool. Keep the unfiltered batch available, because "how many did we start with" is nearly always the next question. And name intermediate results, oregon, by_value, top_two, rather than chaining six operations into one line; a chain that produces a surprising answer has to be taken apart before it can be understood, and named steps are already apart.

Read the code

rows = [
    {"notice_id": "A-1", "state": "OR", "amount": 310000, "agency": "GSA"},
    {"notice_id": "A-2", "state": "WA", "amount": 720000, "agency": "DLA"},
    {"notice_id": "A-3", "state": "OR", "amount": 505000, "agency": "GSA"},
]

columns = ["notice_id", "amount"]
selected = [{key: row[key] for key in columns} for row in rows]

oregon = [row for row in rows if row["state"] == "OR"]
by_value = sorted(oregon, key=lambda row: row["amount"], reverse=True)
top_one = by_value[:1]

print(selected[0])
print(len(oregon), "of", len(rows))
print([row["notice_id"] for row in by_value])
print(top_one[0]["notice_id"], top_one[0]["amount"])

The selected line is a comprehension inside a comprehension: the inner one builds a smaller dictionary per row, the outer one runs it over every row. In pandas this is df[["notice_id", "amount"]].

key=lambda row: row["amount"] tells sorted which value to order by. A lambda is a small unnamed function; lambda row: row["amount"] is the same as a def that takes one argument and returns that field.

Each result has a name. selected is a separate reduced-column view of rows; oregon also starts from rows, which still has state. The ranking chain is rows to oregon to by_value to top_one, not selected to oregon.

Inspect the four table snapshots

One source; a selected view and a ranking branch

Preserve the needed fields: rows feeds selected separately. The ranking path is rows → oregon → by_value → top_one.

Filtering selected would fail here: its reduced dictionaries no longer have state. All four snapshots below are exact results of the worked code.

Select · selected

rows (all 3 records)

Copy notice_id and amount

selected · 3 rows, 2 columns
notice_idamount
A-1310000
A-2720000
A-3505000

A-2 remains in this view. This separate branch is not the input to the filter.

Filter · oregon

rows (all 3 records)

Keep state == "OR"

oregon · 2 rows, all 4 columns
notice_idstateamountagency
A-1OR310000GSA
A-3OR505000GSA

A-2 is removed here. A-1 and A-3 keep their original order and fields.

Sort · by_value

oregon

Sort amount descending

by_value · 2 rows, ranked
notice_idstateamountagency
A-3OR505000GSA
A-1OR310000GSA

A-3 moves before A-1. sorted creates a new list; oregon stays in its prior order.

Limit · top_one

by_value

Take slice [:1]

top_one · first ranked row
notice_idstateamountagency
A-3OR505000GSA

A-3 is the largest Oregon opportunity in this fixture, not the largest row overall.

Each table shows all columns actually retained in that named result. The source rows remain unchanged. selected contains new dictionaries; the filter, sorted list and slice still refer to original record dictionaries. These snapshots describe the worked branch structure, not a universal rule that column selection must happen first.

Predict the output

Predict all four printed lines.

Check your prediction
{'notice_id': 'A-1', 'amount': 310000}
2 of 3
['A-3', 'A-1']
A-3 505000

Line 1 has only the two selected columns; state and agency are gone from that copy while remaining in rows. Line 3 is largest-first, so A-3 at 505,000 precedes A-1 at 310,000. A-2, the largest overall at 720,000, remains in rows and selected, but is absent from oregon, by_value and top_one because it is in Washington and was filtered out before the sort.

That last point is the one worth holding on to: a "top by value" list is only meaningful alongside the filter that produced it.

Modify the code

Remove reverse=True from the sorted call and predict lines 3 and 4.

What changes, and why
['A-1', 'A-3']
A-1 310000

The order flips to smallest-first, and top_one now reports the smallest Oregon opportunity while the variable name still says "top".

Nothing raises. The output is a plausible identifier and a plausible amount, and a reviewer has no way to tell from the report that the ranking is upside down. A default that reads as "natural order" is exactly the kind of thing to check when a ranked list looks slightly wrong, and it is worth a test asserting the first result is the largest.

Debug the bug

An assistant was asked for the three largest Oregon opportunities. It produced this and said it returns them largest first.

rows = [
    {"notice_id": "A-1", "state": "OR", "amount": 310000},
    {"notice_id": "A-2", "state": "WA", "amount": 720000},
    {"notice_id": "A-3", "state": "OR", "amount": 505000},
    {"notice_id": "A-4", "state": "OR", "amount": 128000},
]

top = rows.sort(key=lambda row: row["amount"], reverse=True)
top_three = top[:3]
oregon = [row for row in top_three if row["state"] == "OR"]

print([row["notice_id"] for row in oregon])
What's actually wrong

It raises TypeError: 'NoneType' object is not subscriptable at top_three = top[:3], because rows.sort(...) sorts in place and returns None.

Repair that by using sorted(rows, ...) and the program runs, and is still wrong. The order of operations is inverted: it takes the top three of everything, which are A-2 at 720,000, A-3 at 505,000, and A-1 at 310,000, and then filters those three for Oregon, leaving two. A-4, a genuine Oregon opportunity, was discarded by a limit applied before the filter it should have followed.

With a real batch of four hundred rows this is far more damaging. "Top 20 nationally, then filter to Oregon" can easily produce two results, or zero, while the answer to the question actually asked is twenty.

The correct sequence is filter, sort, limit:

oregon = [row for row in rows if row["state"] == "OR"]
by_value = sorted(oregon, key=lambda row: row["amount"], reverse=True)
top_three = by_value[:3]

Two faults in four lines, one loud and one silent. The loud one is fixed in ten seconds; the silent one changes the answer.

Try it yourself

Five rows, one column to filter on, another to rank by. Report the two largest Oregon opportunities, largest first.

Loading this exercise…

Practical challenge (optional)

Optional: add a second sort key so that ties are broken predictably, for example by amount descending and then by notice id ascending. A key returning a tuple sorts by the first element, then the second, which is one of the more useful things to know about sorted. Then say in one sentence why an unstable tie-break makes a report hard to trust across runs. Reports that reorder themselves for no visible reason quietly destroy a reviewer's confidence in everything else on the page.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. Why filter before sorting rather than after?
  2. What is the difference between sorted(rows, ...) and rows.sort(...)?
  3. What does df[df["state"] == "OR"] do, and what is the inner expression?
  4. Why is "top three, then filter" different from "filter, then top three"?
Answers
  1. Sorting rows you are about to discard is wasted work, and on a large table sorting is the expensive step.
  2. sorted returns a new list and leaves the original alone; sort rearranges in place and returns None, so assigning its result gives you None.
  3. It keeps the rows where the state is Oregon. The inner expression produces a column of True/False values, a boolean mask, which the outer indexing then uses to select rows.
  4. The first limits the whole table before filtering, so genuine matches ranked below the global cut-off are lost. The second answers the question that was actually asked.

Sign in to track your progress on this exercise.

Summary and next step

Retain fields needed by later operations, filter before the top-result limit, sort before slicing for rank, and keep a separate selected-column view when needed; sorted returns a new list while sort returns None; name the intermediate steps; and a ranked list means nothing without the filter that produced it. Next: grouping, where one batch becomes one row per agency and every number needs its denominator stated.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.