Expert · Lesson 81 of 94

Git essentials for Python projects

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

Git records the history of a project as a chain of commits, each a snapshot of every tracked file plus a message saying why. It is how you undo, how you understand why a line exists, how two people work on one codebase, and how code gets from your laptop to production. Every Python project you ship will live in a Git repository, and the habits in this lesson are the ones reviewers notice first: small commits, clear messages, and a repository that contains only what belongs there.

Git essentials for Python projects: working tree: your files → staging area: what the next commit will contain → history: commit → commit → commit
Git essentials for Python projects · Figure 1 · Open for full size

The daily loop is edit, git add to stage exactly the changes that belong together, git commit -m "..." to record them, git status and git diff to see what is pending. Stage deliberately: git add -p walks through hunks so one commit can hold one logical change even when you edited three things. git log --oneline shows the history; git show <commit> shows one change; git blame file shows who last touched each line and in which commit, which is how you find the reason behind code.

Command Does
git init start tracking the current folder
git status / git diff what changed; what is staged
git add -p stage hunk by hunk, so a commit is one logical change
git commit -m "msg" record the staged changes
git log --oneline --graph the history, compact
git switch -c feature/x create and move to a branch
git merge feature/x bring a branch's commits into the current one
git restore file / git restore --staged file undo working changes / unstage
git stash / git stash pop park uncommitted work and bring it back

A Python project's .gitignore is not optional. __pycache__/, *.pyc, .venv/, .pytest_cache/, .mypy_cache/, .ruff_cache/, dist/, build/, *.egg-info/, and .env must never be committed: caches and environments are large and machine-specific, and .env holds secrets. Commit pyproject.toml, the lockfile (uv.lock or requirements.txt), tests, and source. A secret that reaches a commit is compromised even if you delete it in the next one, because history keeps it; rotate the secret and use tools that scan commits before they leave your machine.

Branches make it safe to work on something unfinished. git switch -c feature/parser creates a branch; commits go there; main stays releasable. When the work is done, merge, ideally through a pull request on GitHub or GitLab where a teammate reviews the diff. A conflict happens when two branches changed the same lines; Git marks the file with <<<<<<< and >>>>>>> sections, you edit it to the version you want, git add it, and complete the merge. Conflicts are normal and mechanical, not emergencies.

Commit messages are documentation for the future reader, who is usually you. The first line is a short imperative summary, "Add retry to payment client", under about fifty characters; a blank line; then the why, not the what, because the diff already shows the what. Reviewing a change means reading the diff for correctness, naming, tests, and anything that will surprise the next person, and saying so specifically and kindly. Small commits and small pull requests are what make review possible at all.

Git essentials for Python projects: main → feature/parser: 3 small commits → pull request: diff + description → review: correctness, naming, tests → merge into main
Git essentials for Python projects · Figure 2 · Open for full size

Code

A repository from nothing: init, a Python-appropriate .gitignore, the first commit, and a look at the history. Identity is set locally so the transcript is stable; you set yours once with --global.

# init_repo.sh
mkdir -p demo && cd demo && git init -q -b main
git config user.name "Ada" && git config user.email "ada@example.com"
printf '__pycache__/\n*.pyc\n.venv/\n.pytest_cache/\n.mypy_cache/\n.ruff_cache/\ndist/\n.env\n' > .gitignore
printf 'def total(prices):\n    return sum(prices)\n' > pricing.py
mkdir -p __pycache__ .venv && touch __pycache__/x.pyc .venv/ignored .env
git status --short
git add .gitignore pricing.py
git commit -q -m "Add pricing module with total()"
git log --oneline | sed -E 's/^[0-9a-f]+ /<hash> /'
Output
?? .gitignore
?? pricing.py
<hash> Add pricing module with total()

Ignored files stay invisible, staged hunks are chosen deliberately, and the diff shows exactly what a commit will contain.

# stage_diff.sh
cd demo
printf 'def total(prices, tax_rate=0.0):\n    return round(sum(prices) * (1 + tax_rate), 2)\n\n\ndef discount(price, percent):\n    return round(price * (1 - percent / 100), 2)\n' > pricing.py
git status --short
git diff --stat | tail -1
git add pricing.py
git commit -q -m "Add tax rate to total() and a discount() helper

Prices need tax applied at the point of totaling, and the checkout
flow needs percentage discounts; both live with the other pricing math."
git log --oneline | sed -E 's/^[0-9a-f]+ /<hash> /'
git show --stat HEAD | grep -E "pricing.py|Add tax"
Output
 M pricing.py
 1 file changed, 6 insertions(+), 2 deletions(-)
<hash> Add tax rate to total() and a discount() helper
<hash> Add pricing module with total()
    Add tax rate to total() and a discount() helper
 pricing.py | 8 ++++++--

A branch, a merge, and a conflict resolved. Both branches edit the same line of pricing.py; the markers show the two versions, and the fix is to write the line you want and commit.

# branch_conflict.sh
cd demo
git switch -q -c feature/rounding
sed -i.bak 's/return round(sum(prices) \* (1 + tax_rate), 2)/return round(sum(prices) * (1 + tax_rate), 4)/' pricing.py && rm pricing.py.bak
git commit -q -am "Round totals to 4 places for currency conversion"
git switch -q main
sed -i.bak 's/return round(sum(prices) \* (1 + tax_rate), 2)/return round(sum(prices) * (1 + tax_rate), 3)/' pricing.py && rm pricing.py.bak
git commit -q -am "Round totals to 3 places"
git merge feature/rounding 2>&1 | grep -E "CONFLICT|Automatic"
grep -nE "^(<<<<<<<|=======|>>>>>>>)" pricing.py
printf 'def total(prices, tax_rate=0.0):\n    return round(sum(prices) * (1 + tax_rate), 4)\n\n\ndef discount(price, percent):\n    return round(price * (1 - percent / 100), 2)\n' > pricing.py
git add pricing.py && git commit -q -m "Merge feature/rounding: keep 4 places"
git log --oneline --graph | sed -E 's/[0-9a-f]{7}/<hash>/'
Output
CONFLICT (content): Merge conflict in pricing.py
Automatic merge failed; fix conflicts and then commit the result.
2:<<<<<<< HEAD
4:=======
6:>>>>>>> feature/rounding
*   <hash> Merge feature/rounding: keep 4 places
|\  
| * <hash> Round totals to 4 places for currency conversion
* | <hash> Round totals to 3 places
|/  
* <hash> Add tax rate to total() and a discount() helper
* <hash> Add pricing module with total()

Finding the reason behind a line with blame and show, undoing safely with restore, and parking work with stash.

# history_undo.sh
cd demo
git blame -s pricing.py | sed -E 's/^[0-9a-f]+ +/<hash> /' | head -2
git log -1 --format=%B $(git blame -l -L 2,2 pricing.py | cut -d' ' -f1) | head -1
echo "print('scratch')" >> pricing.py
git status --short
git stash -q && git status --short && echo "(clean after stash)"
git stash pop -q && git restore pricing.py && git status --short && echo "(clean after restore)"
Output
<hash> 1) def total(prices, tax_rate=0.0):
<hash> 2)     return round(sum(prices) * (1 + tax_rate), 4)
Round totals to 4 places for currency conversion
 M pricing.py
(clean after stash)
(clean after restore)

Try it yourself

Put the expense library from the Advanced project under Git: init, a proper .gitignore, and one commit per module with a message that says why the module exists. Create a branch, add the Budget feature from that lesson's exercise in two commits, and merge it. Then deliberately create a conflict on one line from two branches, resolve it, and read git log --graph. Finally, run git blame on one file and follow a line back to the commit message that explains it.

Common mistakes

What's next

Your code has a history now. Next you will give it a production shape: a src layout, pyproject.toml, and locked dependencies.

Course overview