Python Course Part 6: The Modern Developer's Toolkit

By now you can write working Python. This post is about writing Python that other Python developers nod at — code that's Pythonic.
In Part 5 we wrote loops like this to filter and collect data:
valid_ips = []
for line in lines:
if is_valid(line):
valid_ips.append(line)
Four lines, a throwaway empty list, and a manual .append(). There's nothing wrong with it. But Python gives you a way to say the same thing in one line that reads almost like English — and once it clicks, you'll see it everywhere in real codebases.
Today we master list comprehensions, their dict and set cousins, generators, and lambda functions. By the end we'll build a web-server access-log analyzer that turns raw log lines into a security report — mostly in one-liners.
Here's the target:
--- Access Log Report ---
Total requests: 12
Unique visitors: 5
Failed requests (4xx): 4
Top talker: 192.168.1.50 (4 requests)
Suspicious IPs (more than 2 failed requests):
- 10.0.0.99 -> 3 failures
🛑 Dev Callout: Comprehensions Are LINQ
Coming from C#? A list comprehension is
.Where().Select()collapsed into one expression. These two say the same thing:// C# var names = users.Where(u => u.Active).Select(u => u.Name).ToList();# Python names = [u.name for u in users if u.active]Java devs: same idea as a
Streampipeline (.filter().map().collect()), minus the.stream()and.collect()ceremony. The mental model — take a source, optionally filter it, transform each item, collect the result — is identical. Python just bakes it into the language syntax.
1. The List Comprehension
The shape is always the same: [expression for item in iterable]. Read it right to left at first: "for each item in the iterable, evaluate the expression, collect the results into a list."
# The long way:
squares = []
for n in range(1, 6):
squares.append(n * n)
# The comprehension:
squares = [n * n for n in range(1, 6)]
print(squares) # [1, 4, 9, 16, 25]
Same result, one line, no temporary empty list to manage.
2. Adding a Filter
Tack an if onto the end to keep only the items you want. This is the .Where() / .filter() half of the pipeline:
numbers = [4, 7, 10, 13, 16, 19, 22]
evens = [n for n in numbers if n % 2 == 0]
print(evens) # [4, 10, 16, 22]
Now the security version. Given a list of log lines, keep only the failed ones:
lines = [
"192.168.1.50 GET /index.html 200",
"10.0.0.99 POST /login 403",
"192.168.1.50 GET /admin 404",
]
failures = [line for line in lines if line.endswith(("403", "404", "500"))]
print(len(failures)) # 2
(str.endswith() accepts a tuple and returns True if the string ends with any of them — a tidy little trick.)
3. Transforming Each Item
The expression part (the bit before for) is where you reshape data. It can be any expression — a method call, some math, a slice. Here we split each log line into its pieces and grab just the IP:
lines = ["192.168.1.50 GET /index.html 200", "10.0.0.99 POST /login 403"]
ips = [line.split()[0] for line in lines]
print(ips) # ['192.168.1.50', '10.0.0.99']
You can also embed a conditional expression (Python's ternary) inside the transform. Its shape reads naturally: value_if_true if condition else value_if_false.
status_codes = [200, 403, 500, 404, 200]
labels = ["OK" if code < 400 else "FAIL" for code in status_codes]
print(labels) # ['OK', 'FAIL', 'FAIL', 'FAIL', 'OK']
Don't let the two ifs confuse you. The one after else-less... actually, let's be precise: a trailing if (with no else) filters; an if/else before the for transforms. You can even use both in one comprehension — though if it stops being readable, just write the loop.
4. Dict and Set Comprehensions
Swap the square brackets for curly braces and you get the other two.
A set comprehension builds a set (deduplicated, unordered) — perfect for "the unique X":
lines = ["192.168.1.50 ... 200", "10.0.0.99 ... 403", "192.168.1.50 ... 404"]
unique_visitors = {line.split()[0] for line in lines}
print(unique_visitors) # {'192.168.1.50', '10.0.0.99'}
A dict comprehension uses key: value syntax to build a dictionary:
servers = ["web-01", "web-02", "db-01"]
# Map each server name to its length, as a quick example:
name_lengths = {name: len(name) for name in servers}
print(name_lengths) # {'web-01': 6, 'web-02': 6, 'db-01': 5}
5. Lambdas — Tiny Throwaway Functions
A lambda is a one-line, unnamed function. The syntax is lambda arguments: expression, and it returns the expression automatically (no return keyword).
double = lambda x: x * 2
print(double(5)) # 10
You will almost never assign a lambda to a name like that — if it deserves a name, use def. Where lambdas genuinely shine is as the key argument to built-ins like sorted(), max(), and min(), where you need a quick "sort/compare by this" rule:
servers = [
{"name": "web-01", "load": 0.82},
{"name": "db-01", "load": 0.31},
{"name": "web-02", "load": 0.95},
]
# Sort by the 'load' field, highest first:
busiest_first = sorted(servers, key=lambda s: s["load"], reverse=True)
print(busiest_first[0]["name"]) # web-02
# Or just grab the single busiest:
hot = max(servers, key=lambda s: s["load"])
print(hot["name"]) # web-02
The key function tells sorted/max/min what to compare. Without it, Python wouldn't know which field of the dictionary you care about.
6. Generators — Comprehensions That Don't Blow Up Your RAM
Swap the square brackets for parentheses and you get a generator expression. It looks almost identical to a list comprehension but behaves very differently: it produces items one at a time, on demand, instead of building the whole list in memory at once.
# List comprehension — builds all 10 million numbers in memory immediately:
total = sum([n * n for n in range(10_000_000)])
# Generator expression — feeds one number at a time into sum(), holding ~one in memory:
total = sum(n * n for n in range(10_000_000))
Both give the same answer. The second uses a tiny, constant amount of memory. This is the same memory lesson from Part 5 (streaming a file line by line), now as a language feature.
🛑 Dev Callout: Generators Are Lazy
IEnumerablesA generator is Python's
yield-based lazy sequence — conceptually the same as a C#IEnumerable<T>built withyield return, or a JavaStreamyou haven't terminated yet. Nothing is computed until something pulls from it (aforloop,sum(),list()). Use a list comprehension when you need the full collection in hand (you'll index it, loop it twice, check its length). Use a generator when you're going to consume it exactly once and the source is large — you trade random access for near-zero memory.
7. Bringing It Together: The Access Log Analyzer
Let's put all of it to work. We'll take a batch of raw access-log lines and produce a small security report — and you'll see how much of the heavy lifting collapses into comprehensions.
We'll use one helper from the standard library: collections.Counter, a dictionary subclass purpose-built for tallying things.
Create a file named log_analyzer.py:
from collections import Counter
# In Part 5 you'd read these from a file. Inlined here so the script just runs.
# Format: "<ip> <method> <path> <status>"
raw_lines = [
"192.168.1.50 GET /index.html 200",
"10.0.0.99 POST /login 403",
"192.168.1.50 GET /admin 404",
"172.16.0.1 GET /index.html 200",
"10.0.0.99 POST /login 403",
"192.168.1.50 GET /admin 404",
"203.0.113.7 GET /index.html 200",
"10.0.0.99 POST /login 403",
"192.168.1.50 GET /index.html 200",
"172.16.0.1 GET /style.css 200",
"203.0.113.7 GET /missing 404",
"8.8.8.8 GET /index.html 200",
]
# Split each line into (ip, method, path, status) once, up front.
# A list of tuples is easy to pull fields out of below.
records = [tuple(line.split()) for line in raw_lines]
# --- Now the comprehensions do the analysis ---
all_ips = [ip for ip, method, path, status in records]
unique_visitors = {ip for ip, method, path, status in records}
# A "failure" is any 4xx status. The status field is a string, so compare as strings.
failed_ips = [ip for ip, method, path, status in records if status.startswith("4")]
# Tally requests per IP and failures per IP:
requests_per_ip = Counter(all_ips)
failures_per_ip = Counter(failed_ips)
# The single most active IP. Counter.most_common(1) returns [(ip, count)].
top_ip, top_count = requests_per_ip.most_common(1)[0]
# Anyone with more than 2 failed requests is worth flagging. Dict comprehension
# filtering an existing dict:
suspicious = {ip: count for ip, count in failures_per_ip.items() if count > 2}
# --- Report ---
print("--- Access Log Report ---")
print(f"Total requests: {len(records)}")
print(f"Unique visitors: {len(unique_visitors)}")
print(f"Failed requests (4xx): {len(failed_ips)}")
print()
print(f"Top talker: {top_ip} ({top_count} requests)")
print()
print("Suspicious IPs (more than 2 failed requests):")
if suspicious:
for ip, count in suspicious.items():
print(f" - {ip} -> {count} failures")
else:
print(" (none)")
Run it and read the report against the data. Then try this: count how many lines of imperative for/if/append it would take to replace the comprehensions. The comprehension version isn't just shorter — once you're fluent, it's easier to read, because each line states one transformation instead of a multi-line recipe you have to mentally execute.
A word of caution to balance the enthusiasm: comprehensions are for building a collection. If your loop has side effects (printing, writing files, calling an API), or if a comprehension grows three clauses deep and stops fitting on one line, write the plain loop. Pythonic means readable, not shortest.
What's Next?
You can now reshape data with real fluency. But every program we've built so far has kept its data in loose, separate variables and dictionaries — an IP here, a status count there. As systems grow, you want to bundle data and the behaviour that operates on it into a single, well-defined thing.
In Part 7: Object-Oriented Python (Without the Headache), we'll meet classes — and then jump straight to modern dataclasses, which give you clean, structured types with almost none of the boilerplate you might remember from Java.