Your Portfolio, and How to Ask a Question That Gets Answered
Show the work, and make it easy to help you
In this lesson
Everything so far has been about making code work. This lesson is about the two moments where other people are involved: showing them what you built, and asking them for help.
Both are skills, both are learnable, and both are done badly by default.
Explain it like I’m 5
A portfolio is a small museum of what you can build. A good question is one that hands over every clue the answerer needs.
Three finished projects beat thirty tutorials
The thing anyone looking at your work wants to know is whether you can finish something. Nothing else in a portfolio matters as much.
Which means the selection rules are short:
- Finished beats ambitious. A small tool that works beats a half-built framework, every time.
- Yours beats followed. A tutorial project is fine if you changed it and the README says what you changed and why.
- Different beats similar. Three near-identical to-do apps show one skill. A CLI tool, a data analysis, and a small web app show three.
- Solving your own problem beats an exercise. It gives you something to say about it, and you will have made real decisions.
Three is plenty. Nobody reads the fourth.
The README is the project, as far as a visitor is concerned
Most people will read your README and never open a source file. If it does not say what the thing does in the first two lines, they leave.
A good README answers five questions in order, and nothing else is required:
# tidyup
Sorts a messy downloads folder into dated subfolders. I wrote it because
mine had 4,000 files in it.

## What it does
Groups files by the month they were modified, moves them into `2026-07/`
style folders, and never overwrites: a clash becomes `report (2).pdf`.
## Install
pip install git+https://github.com/ada/tidyup
## Use it
tidyup ~/Downloads --dry-run # show what would happen
tidyup ~/Downloads # actually do it
sorted 84 files into 6 dated folders
## How it works
A single pass with `pathlib`, grouping by `st_mtime`. The move and the
name-clash logic are separated so both can be tested without real files.
## What I would add next
Undo. Every move is currently one-way, which is the main thing stopping
me from using it without `--dry-run` first.
Check what you are about to make public
Before anything goes on the internet (a repository, a screenshot, a pasted traceback), check it for things that should not.
- API keys and tokens, including in the git history. Deleting a key in a later commit does not remove it; the old commit is still there.
- Passwords and connection strings, which love to hide in a
settings.pyor a notebook cell. - Real personal data in sample files. Swap in fake names.
- Absolute paths —
/Users/ada/...tells strangers your full name. - Anything in a screenshot: browser tabs, notifications, the file tree.
The habit that prevents all of it is the one from Unit 7: keys live in environment variables and a .env file, and .env is in .gitignore from the first commit. Then there is nothing to remember later.
import re
# What you were about to paste into a public forum.
SNIPPET = """
import requests
API_KEY = "sk_live_9f2Ab7QxT1kZ"
DB = "postgres://ada:[email protected]:5432/sales"
response = requests.get(URL, headers={"Authorization": "Bearer ghp_ZZt4mQ1"})
"""
PATTERNS = [
(r'(sk_live_|ghp_)[A-Za-z0-9]+', r'\1REDACTED'),
(r'(postgres://[^:]+:)[^@]+(@)', r'\1REDACTED\2'),
]
safe = SNIPPET
for pattern, replacement in PATTERNS:
safe = re.sub(pattern, replacement, safe)
print(safe.strip())
import requests API_KEY = "sk_live_REDACTED" DB = "postgres://ada:[email protected]:5432/sales" response = requests.get(URL, headers={"Authorization": "Bearer ghp_REDACTED"})
A question that gets answered
People answer questions that are easy to answer. That is not cynicism, it is arithmetic: a stranger has two minutes, and the question that fits in two minutes gets them.
So the difference between a question that gets three replies and one that gets none is almost entirely about how much work you did first.
- What you are trying to do — the goal, not just the error. Otherwise you get help with the wrong approach.
- The smallest code that reproduces it — the shrinking step from the debugging lesson, doing a second job.
- The full traceback, as text, not a screenshot. People need to search it.
- What you expected and what happened.
- What you already tried, so nobody repeats it.
- Versions, when a library is involved.
BAD
---
Subject: pandas not working
my code doesnt work, i get an error. anyone know why?
[screenshot of a phone photo of a monitor]
GOOD
----
Subject: KeyError on a column that df.columns says exists
I am reading a CSV exported from our billing system and selecting one
column. It raises KeyError even though the name is printed by
df.columns.
import pandas as pd
df = pd.read_csv("sales.csv")
print(df.columns.tolist())
# ['region', 'total ']
print(df["total"])
# KeyError: 'total'
Expected: the total column. Got: KeyError.
Tried: reading with sep="," explicitly, and checking for a BOM.
pandas 2.2.0, Python 3.12, macOS.
Score your own draft before posting it. A help request needs all five parts; write missing() to report which are still empty, and ready() to say whether it is worth posting. The draft below has the code and the traceback and is still missing the two that matter most.
REQUIRED = ["goal", "code", "traceback", "expected", "tried"]
DRAFT = {
"goal": "",
"code": 'df = pd.read_csv("sales.csv")\nprint(df["total"])',
"traceback": "KeyError: \'total\'",
"expected": "",
"tried": "reading with sep=\',\' explicitly",
}
def missing(draft):
"""Which required parts are empty or absent?"""
return [] # TODO
def ready(draft):
"""Is this worth posting yet?"""
return False # TODO: build this on top of missing()
print("missing:", missing(DRAFT))
print("ready to post:", ready(DRAFT))
DRAFT["goal"] = "select one column from a CSV export"
DRAFT["expected"] = "the total column; got KeyError"
print("after filling those in:", ready(DRAFT))
missing is one comprehension over REQUIRED, keeping any part where draft.get(part, "").strip() is falsy, using .get so an absent key counts as missing, and .strip() so a value of only spaces does too. Then ready is simply not missing(draft), so the two can never disagree.
REQUIRED = ["goal", "code", "traceback", "expected", "tried"]
DRAFT = {
"goal": "",
"code": 'df = pd.read_csv("sales.csv")\nprint(df["total"])',
"traceback": "KeyError: \'total\'",
"expected": "",
"tried": "reading with sep=\',\' explicitly",
}
def missing(draft):
"""Which required parts are empty or absent?"""
return [part for part in REQUIRED if not draft.get(part, "").strip()]
def ready(draft):
"""Is this worth posting yet?"""
return not missing(draft)
print("missing:", missing(DRAFT))
print("ready to post:", ready(DRAFT))
DRAFT["goal"] = "select one column from a CSV export"
DRAFT["expected"] = "the total column; got KeyError"
print("after filling those in:", ready(DRAFT))
missing: ['goal', 'expected']
ready to post: False
after filling those in: True
Contributing, and what comes after
Open source is the least intimidating it has ever been, and the way in is not code.
Documentation is the standard first contribution for a reason: you are reading the docs as a newcomer right now, which makes you the only person who can see what is missing. A fixed typo, a clarified sentence, or an example that was absent is a genuine contribution and gets you through the pull-request process once, on something low-stakes.
After that, look for issues labeled good first issue, read CONTRIBUTING.md before writing anything, and start with a project you actually use: you will understand the problem, and you will care whether the fix lands.
As for where this leads: Python turns up in automation, data analysis, back-end web work, scientific computing, testing, and operations. The most common route into any of them is not a certificate. It is a handful of finished projects, a README that explains them, and being able to talk about a decision you made and why.
Common mistake: Listing tutorial projects without saying what you changed
It is a real project and you did build it.
A reader cannot tell your work from the tutorial's. Add a line saying what you added, changed or fixed. That line is the whole value of the entry.
Common mistake: Posting an error with no code and no traceback
The error message feels like the important part.
Nobody can reproduce a message. Include the smallest failing code and the full traceback as text, and you will usually get an answer within the hour.
Common mistake: Screenshotting a traceback instead of copying it
Screenshots are quicker to take.
They cannot be searched, quoted, or read on a phone. Paste the text in a code block.
Common mistake: Sharing secrets in code or screenshots
The key was in the file you were debugging, so it comes along.
Redact before posting, and check the whole screenshot, not just the terminal. Treat anything that was public for even a moment as compromised and rotate it.
Common mistake: Waiting until a project is perfect before sharing it
You can see every rough edge and assume everyone else will too.
Nothing is ever finished. Ship it with a “what I would add next” section, which turns the rough edges into evidence of judgment.
What belongs in a README?
Answer the visitor's questions in the order they have them. The first two lines decide whether they read the rest.
What makes a question easy to answer?
Answerers have two minutes. Everything you supply is time they do not have to spend guessing.
What must you remove before sharing code publicly?
And remember the git history: a key deleted in a later commit is still in the earlier one.
What is the most common first open-source contribution?
As a newcomer you can see what the docs assume, which nobody who wrote them can. It is a real contribution and a gentle first pull request.
Mini exercise (hard)
Write the check you run before pasting anything into a public forum. audit(text) returns a sorted list of the kinds of secret it found: "api key" for anything starting sk_live_ or ghp_, "password" for a database URL with credentials in it, and "home path" for an absolute /Users/<name> or /home/<name> path. Then redact(text) returns the text with all three masked.
Take the wheel. Complete the code, hit Run, and check your output right here.
import re
SNIPPET = """API_KEY = "sk_live_9f2Ab7QxT1kZ"
DB = "postgres://ada:[email protected]:5432/sales"
LOG = "/Users/ada/projects/tidyup/run.log"
"""
CLEAN = 'response = requests.get(URL, timeout=10)'
# TODO: (label, pattern, replacement) - keep the recognizable prefix with \\1
RULES = []
def audit(text):
"""Sorted labels for every kind of secret found."""
return [] # TODO
def redact(text):
"""The same text with every match masked."""
return text # TODO
print("found:", audit(SNIPPET))
print("clean:", audit(CLEAN))
print()
print(redact(SNIPPET).strip())
Build one list of (label, pattern, replacement) and use it for both functions: audit keeps the labels where re.search finds a match, and redact runs re.sub for every entry. Use a capture group for the part you want to keep, and refer to it as \1 in the replacement.
import re
SNIPPET = """API_KEY = "sk_live_9f2Ab7QxT1kZ"
DB = "postgres://ada:[email protected]:5432/sales"
LOG = "/Users/ada/projects/tidyup/run.log"
"""
CLEAN = 'response = requests.get(URL, timeout=10)'
RULES = [
("api key", r"(sk_live_|ghp_)[A-Za-z0-9]+", r"\1REDACTED"),
("password", r"(postgres://[^:/]+:)[^@]+(@)", r"\1REDACTED\2"),
("home path", r"(/(?:Users|home)/)[^/\s\"']+", r"\1REDACTED"),
]
def audit(text):
"""Sorted labels for every kind of secret found."""
return sorted({label for label, pattern, _ in RULES if re.search(pattern, text)})
def redact(text):
"""The same text with every match masked."""
for _label, pattern, replacement in RULES:
text = re.sub(pattern, replacement, text)
return text
print("found:", audit(SNIPPET))
print("clean:", audit(CLEAN))
print()
print(redact(SNIPPET).strip())
found: ['api key', 'home path', 'password']
clean: []
API_KEY = "sk_live_REDACTED"
DB = "postgres://ada:[email protected]:5432/sales"
LOG = "/Users/REDACTED/projects/tidyup/run.log"
assert audit(CLEAN) == [], "harmless code should raise nothing"
assert audit(SNIPPET) == ["api key", "home path", "password"], "all three kinds are present, sorted"
assert audit('token = "ghp_ZZt4mQ1"') == ["api key"], "a GitHub token is an api key too"
assert "hunter2" not in redact(SNIPPET), "the database password must be gone"
assert "9f2Ab7QxT1kZ" not in redact(SNIPPET), "the api key must be gone"
assert "sk_live_" in redact(SNIPPET), "keep the prefix so the reader knows a key was there"
assert redact(CLEAN) == CLEAN, "clean text must come back unchanged"
print("✓ Looks good!")