Difficulty: Beginner | ~25 min | Requires basic Python (lists, dicts, functions)
Functional Data Wrangling with Lambda Functions
In this lab, we will learn lambda functions — Python's anonymous, one-expression
functions — and how to compose them with filter(), map(), and sorted() to turn a
messy JSON product catalog into a clean, ranked table in one pipeline.
You are a data analyst at an online electronics store. Marketing exported the product
catalog to JSON, and it's messy: prices are sometimes strings or null, some are
buried in a nested pricing dict that records its own currency, and stock counts go
missing. Your manager wants a quick answer: which products are actually sellable —
not discontinued, in stock, and with a usable price — priced in euros at a 10%
clearance discount, ranked by category and then by price? Rather than write a def
per step, this lab composes filter(), map(), and sorted() with short lambdas
into one clean pipeline.
A JSON file, data/product_catalog.json, shipped with the lab: 37 product
dictionaries across 5 categories, deliberately messy (missing fields, string and
null prices, mixed currencies) so you practice defending against real-world data.
Nothing to download, no API keys.
- Warm up on plain numbers — try each of the four tools (
lambda,map,filter,sorted) on a simple list of numbers, no mess to distract you. - Inspect the mess — print the actual price types and stock values so you know what your code has to survive.
- Part 1 —
filter()+ lambda: keep only products that are not discontinued, have stock greater than zero (defaulting toqty, then to0), and have a usable (non-None) price. - Part 2 — normalize, then reshape with
map()+ lambda: first a small named functionto_usd()normalizes the mixed currencies (USD/EUR/GBP) to a single USD figure; then the lambda converts to euros (rate 0.92), applies a 10% discount, and rounds to 2 decimals. - Part 3 —
sorted()+ lambda key: order the final dictionaries by category (A–Z), then by price within each category, highest first — one tuple key. - Full pipeline: compose
filter → map → sortedinto a single expression.
Each cell prints the result of the step it just demonstrated. The final output is a
ranked table of 21 sellable products, in euros with a 10% clearance discount, ordered
by category (A-Z) and then by price (highest first), with columns for Category,
Name, Price (EUR), and Sale (EUR).
- Python 3.9+ —
json(loading the catalog) andlambda/map/filter/sortedare all built into the standard library. - tabulate == 0.10.0 — used only to display the final result as a neat table.
- Data file:
data/product_catalog.jsonships with the lab.
No GPU, no API keys, no paid services. Runs on any laptop CPU with a few MB of RAM.
A lambda is a function without a name, written lambda parameters: expression.
The expression is automatically returned, so no return keyword is needed — but a
lambda can hold only a single expression. Its value here is exactly that you can write
a small transformation inline, where it is used, instead of scattering one-use def
functions through the notebook.
map(function, iterable)applies the function to every item and returns the results. It is a transform step: same number of items in, same number out. Here it is used to reshape — turning each messy product dictionary into a clean one.filter(function, iterable)keeps only the items for which the function returnsTrue. It is a selection step: usually fewer items come out than went in.sorted(iterable, key=function)returns a new list in a chosen order. Thekeyfunction is called once per item, andsorted()orders the items by the values that key returns. Without akey,sorted()on a list of dictionaries would fail — Python can't compare whole dictionaries.
Both map() and filter() return lazy objects, which is why the lab wraps them in
list() to see the results.
A JSON export is not a clean API — numbers arrive as strings, null means "no value",
fields go missing, and the same field is sometimes stored under a different key. Three
tricks do most of the work:
.get(key, default)returnsdefaultinstead of raisingKeyErrorwhen the key is absent. Chained with a second key —product.get("stock", product.get("qty", 0))— it walks two candidate keys and returns0only when both are missing.float()coerces string prices ("34.99"→34.99) so arithmetic works on mixed data. You must first make sure the price isn'tNone— a JSONnulldecodes to PythonNone, andfloat(None)would crash.- Currency normalization — when prices arrive in different currencies, comparing
or summing them is meaningless until every price is in the same unit.
to_usd()convertsEURandGBPprices to USD before the lab applies the EUR conversion.
This is why the filter runs first: once discontinued, out-of-stock, and unpriceable products are gone, the later stages can assume the records are well-formed.
sorted() compares key values once per item. If the key returns a tuple, Python
compares the first element, breaking ties with the second (then the third, and so on).
That gives two-level sorting — but only in ascending order for both. To make one
element sort descending while the other stays ascending, negate the numeric one:
(product["category"], -product["price_eur"]). The minus sign flips only the price.
%%{init: {"theme": "base", "themeVariables": {"nodeTextColor": "#111111", "primaryTextColor": "#111111", "textColor": "#111111", "lineColor": "#334155", "edgeLabelBackground": "#ffffff"}}}%%
graph LR
RAW["Messy catalog<br/>37 products, mixed types,<br/>null prices, mixed currencies"]
FILTER["filter()<br/>drop discontinued,<br/>out-of-stock, unpriced"]
MAP["map()<br/>to_usd() -> EUR<br/>+ 10% discount"]
SORT["sorted()<br/>category A-Z,<br/>price desc"]
OUT["Clean answer<br/>ranked table"]
RAW --> FILTER --> MAP --> SORT --> OUT
style RAW fill:#e1f5ff,color:#111111
style FILTER fill:#fff9c4,color:#111111
style MAP fill:#fff9c4,color:#111111
style SORT fill:#fff9c4,color:#111111
style OUT fill:#c8e6c9,color:#111111
Read pipelines from the inside out: the innermost function runs first. filter
runs on the raw catalog, map reshapes what survived, and sorted ranks the final
dictionaries.
- Basic Python: lists, dictionaries,
forloops, and simpledeffunctions. - Comfort with reading
dict.get(key, default)and type conversions likefloat(). If you've never used.get(), the lab explains it inline in Part 1. - No prior functional programming or lambda experience needed — that's the whole point of this lab.
- No accounts, API keys, or hardware requirements.
You need Python 3.9 or newer. This lab uses only the standard library — json,
lambda, map(), filter(), sorted() — plus one third-party package to display
the final table:
# Check your Python version (must be 3.9+)
python --version
# Install the one dependency
pip install tabulate==0.10.0The notebook's Step 1 — Install the dependency cell runs
pip install tabulate==0.10.0, so running that cell installs everything you need. Keep
the data/ folder with product_catalog.json next to the notebook.
Work through the notebook cell by cell. Each step is explained below with the exact code that runs.
Before the messy catalog, see the four tools in their simplest form on a list of
numbers — same syntax, no missing fields to defend against. list() materializes the
lazy results of map() and filter().
# A lambda is an anonymous one-expression function; the value after the colon
# is returned implicitly.
double = lambda x: x * 2
print(double(5))
# map() applies a function to every item; list() materializes the lazy result.
numbers = [1, 2, 3, 4, 5]
doubled = list(map(lambda x: x * 2, numbers))
print(doubled)
# filter() keeps only the items where the lambda returns True.
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)
# sorted() reorders; the key lambda picks what to sort by, and -x flips the
# order to descending.
ranked_numbers = sorted(numbers, key=lambda x: -x)
print(ranked_numbers)This prints 10, [2, 4, 6, 8, 10], [2, 4], and [5, 4, 3, 2, 1].
The lab uses one third-party package: tabulate, which turns a list of rows into a
clean text table. Everything else — json, lambda, map, filter, sorted — is
built into Python, so this is the only install you'll ever need.
import sys
print(sys.executable) # confirm which Python the kernel uses
!{sys.executable} -m pip install tabulate==0.10.0Read the catalog from the JSON file that ships with the lab. Because the notebook runs
from the lab folder, the relative path data/product_catalog.json works. The result is
exactly the kind of inconsistent export described in Section 3.
# Manual open/load/close; Python's "with" statement automates this — covered in Lab 2.
import json
f = open("data/product_catalog.json", encoding="utf-8")
catalog = json.load(f)
f.close()
print("Loaded", len(catalog), "product records.")This prints Loaded 37 product records.
Note on with. The lines above open the file, load the JSON, and close it — that's
the manual way to read a file. Python also has a with statement that opens the
file and closes it for you automatically, even if the code inside the block raises an
error. We don't use it in this lab; it's covered in detail in Lab 2 (The Safe Resource
Vault — Context Managers).
Before transforming anything, look at the actual types and missing values so you know
what your code must survive. Prices come as float, str, and None; three records
report MISSING stock; three carry status: "unavailable".
# Print each product's price type and stock value. .get() with a default of
# "MISSING" lets the loop run even for records with no stock key at all.
for product in catalog:
price = product.get("price", product.get("pricing", {}).get("base"))
stock = product.get("stock", product.get("qty", "MISSING"))
print(product["id"], "| price type:", type(price).__name__,
"| price:", repr(price), "| stock:", repr(stock),
"| status:", product.get("status", "-"))Remove discontinued, out-of-stock, and unpriced products in one filter() call. The
lambda does the defensive tricks from Section 7: .get() supplies a default when a
field is missing (walking stock, then qty, then 0), and the final check drops
records whose price is None — you can't sell a product you can't price.
# PART 1 — keep only products you could actually sell today.
# The predicate checks three things: not discontinued, some stock (> 0, counting
# the "qty" fallback key), and a usable price (not None).
active = list(filter(
lambda product: not product["discontinued"]
and product.get("stock", product.get("qty", 0)) > 0
and product.get("price", product.get("pricing", {}).get("base")) is not None,
catalog,
))
print("Active, in-stock products:", len(active))
for product in active:
print(" ", product["id"], product["name"])This prints Active, in-stock products: 21 followed by the 21 product ids.
Prices arrive in three currencies depending on where the record stored them, so a
small named function to_usd() normalizes every price to a single USD figure first.
It is a def, not a lambda, on purpose — it is reused by the rest of the lab
(Steps 7–8), and squeezing two lookup paths plus a currency table into one expression
would be unreadable.
# PART 2a — normalize the mixed currencies to one unit: USD.
# to_usd() extracts the price from either location and converts it to USD, so every
# product is in the same unit before the EUR conversion and discount apply.
usd_per_currency = {"USD": 1.0, "EUR": 1.08, "GBP": 1.27}
usd_to_eur = 0.92
discount_rate = 0.10
def to_usd(product):
if product.get("price") is not None:
return float(product["price"]) * usd_per_currency[product.get("currency", "USD")]
nested = product.get("pricing", {})
return float(nested["base"]) * usd_per_currency[nested.get("currency", "USD")]Now every price is one USD figure, so a single map() with a lambda reshapes each
active product into a clean dictionary: USD → euros (rate 0.92), then a 10% clearance
discount for the sale price, rounding both to 2 decimals.
# PART 2b — convert to EUR, apply the 10% clearance discount, round to 2 decimals.
# One map() with a lambda reshapes each active product into a clean dictionary.
priced = list(map(lambda product: {
"name": product["name"],
"category": product["category"],
"price_eur": round(to_usd(product) * usd_to_eur, 2),
"sale_eur": round(to_usd(product) * usd_to_eur * (1 - discount_rate), 2),
}, active))
for product in priced:
print(product["category"], "-", product["name"], "-> EUR", product["price_eur"])Sort by category (A–Z), then by price within each category, highest first. The key
lambda returns a tuple; Python compares the first element and breaks ties with the
second. The minus sign on the price flips only that element to descending.
# PART 3 — order by category (A-Z), then by price within each category,
# highest first. A tuple key lets sorted() compare two values at once; the
# minus sign flips the price to descending without touching the category.
ranked = sorted(priced, key=lambda product: (product["category"], -product["price_eur"]))
for product in ranked:
print(product["category"], "-", product["name"], "- EUR", product["price_eur"])Everything map() does can also be written as a list comprehension. Both build
the same list; knowing both lets you read (and write) either style in other people's
code.
# map() + lambda and a list comprehension build the same list; pick whichever
# reads better for the task at hand. This is the comprehension twin of Part 2.
priced_alt = [{
"name": product["name"],
"category": product["category"],
"price_eur": round(to_usd(product) * usd_to_eur, 2),
"sale_eur": round(to_usd(product) * usd_to_eur * (1 - discount_rate), 2),
} for product in active]
print("map() and comprehension agree:", priced_alt == priced)Compose the whole job — filter the mess, reshape it, rank it — as one pipeline. Read
it inside out: filter runs first, then map, then sorted. The lambdas are
repeated here deliberately so you can see the full composition in one place; in a real
script you'd factor them into named functions (see Step 10) once they're used more
than once.
# The whole job in one expression: filter the mess, reshape it, rank it.
# Read inside out — filter runs first, then map, then sorted.
final = sorted(
map(
lambda product: {
"name": product["name"],
"category": product["category"],
"price_eur": round(to_usd(product) * usd_to_eur, 2),
"sale_eur": round(to_usd(product) * usd_to_eur * (1 - discount_rate), 2),
},
filter(
lambda product: not product["discontinued"]
and product.get("stock", product.get("qty", 0)) > 0
and product.get("price", product.get("pricing", {}).get("base")) is not None,
catalog,
),
),
key=lambda product: (product["category"], -product["price_eur"]),
)
print("Final ranked list:", len(final), "products")A list of dictionaries is correct but hard to scan. tabulate turns the rows into a
clean, grid-styled table with column headers — a much better way to read the answer.
# A list of dictionaries is correct but hard to scan. tabulate turns rows into
# a clean text table with column headers — a much better way to read results.
from tabulate import tabulate
rows = [(product["category"], product["name"], product["price_eur"], product["sale_eur"]) for product in final]
print(tabulate(rows, headers=["Category", "Name", "Price (EUR)", "Sale (EUR)"], tablefmt="grid"))Finally, the judgment call. The Part 1 predicate is used twice in this lab (Steps 4
and 8) and has three moving parts — the discontinued flag, the missing stock across
two keys, and the null price check. Written as a named function it reads cleanly and
can be reused without re-typing the whole expression. Same result either way; the named
version wins when the logic grows.
# Lambdas win for one-line predicates. But this predicate has three moving
# parts (discontinued flag, missing stock across two keys, null price) — enough
# that a named function reads better and can be reused without re-typing it.
def is_active(product):
stock = product.get("stock", product.get("qty", 0))
price = product.get("price", product.get("pricing", {}).get("base"))
return not product["discontinued"] and stock > 0 and price is not None
via_function = list(filter(is_active, catalog))
via_lambda = list(filter(
lambda product: not product["discontinued"]
and product.get("stock", product.get("qty", 0)) > 0
and product.get("price", product.get("pricing", {}).get("base")) is not None,
catalog,
))
print("Same result:", via_function == via_lambda, "| kept:", len(via_function))Swap the target currency: instead of euros, price everything in pounds sterling
(rate usd_to_gbp = 0.79). Rename usd_to_eur to usd_to_gbp, the price_eur and
sale_eur fields to price_gbp and sale_gbp, and update the table headers and
print statements accordingly. Because the same rate multiplies every price, the
Part 3 ranking order must stay identical — confirm that by comparing the order of the
final table to the one in Section 5.
filter()with a lambda selects:not discontinued and stock > 0 and pricedrops the products you can't sell, in one expression..get(key, default)defends against missing fields — including walking two candidate keys (stock, thenqty) before giving up and returning0.- A
Noneprice is a missing value: JSON exports usenullfor "no data", and your code must check for it before callingfloat(). - Normalize mixed currencies early: one
to_usd()function converts every record to the same unit, so the conversion and discount apply uniformly. map()with a lambda reshapes data: nested, mixed-type dictionaries become flat, clean records with converted prices and discounts applied.sorted()with a tuple key sorts by two (or more) fields at once, and negating a numeric key element (-price) flips just that one to descending.- Pipelines read inside out:
filter → map → sortedturns a messy catalog into a ranked answer in one expression. - filter first: cleaning the data early lets later stages assume well-formed records.
- When not to use a lambda: logic that is reused or grows past one expression
belongs in a named
deffunction — lambdas shine for one-off single expressions.