Most data scientists learn Git right after they need it.
Usually the lesson arrives in one of three forms:
final_model_v2_really_final.ipynb- a teammate overwriting the feature pipeline
- a production result nobody can trace back to the code that created it
Git looks like a software engineering tool, so data teams often treat it as optional infrastructure. Something useful once the model is “ready.” Something the platform team can clean up later.
That is backwards.
Git is part of the experiment system.
It gives a data science team a shared record of what changed, why it changed, who reviewed it, and which version of the code produced a result. It also gives you a safe place to be wrong—which is important, because most modeling work is exactly that: a long sequence of ideas that do not survive contact with the data.
This is the Git workflow I wish more data science and machine learning projects started with.
1) The mental model: snapshot the decisions, not just the files
Git is a version control system. The basic idea is simple: you make changes, select the changes that belong together, and save them as a commit.
The useful unit is not the file.
It is the decision.
Maybe you changed missing-value handling. Maybe you added a lag feature. Maybe you replaced a linear model with a gradient-boosted tree. A good commit captures one of those decisions and gives it a name.
Working directory → Staging area → Commit history
edit select explain
That creates a project history you can reason about.
git status
git add src/features.py tests/test_features.py
git commit -m "handle missing prices in feature pipeline"
The commit message is not administrative overhead. It is a note to your future team about what changed and what the change was supposed to accomplish.
“Update notebook” tells them almost nothing.
“Prevent target leakage in rolling revenue feature” tells them where to look when the validation score changes.
2) Start the project before the project gets interesting
The best time to initialize Git is when the directory is boring.
mkdir demand-forecast
cd demand-forecast
git init
Then create a small project structure that separates code, configuration, tests, notebooks, and generated artifacts.
demand-forecast/
├── config/
│ └── train.yaml
├── notebooks/
│ └── 01-exploration.ipynb
├── src/
│ ├── features.py
│ └── train.py
├── tests/
│ └── test_features.py
├── .gitignore
├── README.md
└── pyproject.toml
Your first commit should establish that shape.
git add .gitignore README.md pyproject.toml config src tests notebooks
git commit -m "initialize demand forecasting project"
This sounds small, but it creates the boundary around the work. From this point forward, every meaningful change has a before and an after.
3) Do not put the whole machine learning project in Git
Git is excellent at versioning text.
Python, SQL, configuration, tests, documentation, and small notebooks are all reasonable candidates.
Git is not a good warehouse for everything your pipeline produces.
Large datasets, serialized models, local environments, caches, credentials, and generated outputs can make the repository enormous or unsafe. The repository should contain what another person needs to understand and reproduce the work—not every byte the work has ever created.
A practical .gitignore might start like this:
# Secrets
.env
.env.*
# Python environments and caches
.venv/
__pycache__/
.pytest_cache/
# Local data and generated artifacts
data/raw/
data/processed/
models/
artifacts/
# Notebook checkpoints
.ipynb_checkpoints/
There is one rule worth stating plainly:
If a secret has been committed, adding it to
.gitignoredoes not remove it from history.
Rotate the credential. Then clean up the repository history if needed.
For large data and model artifacts, store the bytes in object storage or an artifact system and version the reference, metadata, checksum, or manifest in Git. Tools such as DVC can connect those artifacts to Git revisions without stuffing the artifacts themselves into the repository.
The goal is a reproducible relationship:
Git commit + data version + environment + parameters = experiment context
Git solves one part of reproducibility. It does not solve the whole equation.
4) Use a branch as an experiment boundary
Data science work is uncertain by design.
You want to try a new encoding strategy without destabilizing the baseline. You want to compare a tree model with the current linear model. You want to refactor the training pipeline while someone else fixes evaluation.
That is what branches are for.
git switch -c experiment/add-promotion-lags
Now the experiment has a name and an isolated history.
Make the change. Run the tests. Train the model. Compare it with the baseline. Then commit the code and configuration that explain the result.
git add src/features.py config/train.yaml tests/test_features.py
git commit -m "add promotion lag features"
If the experiment fails, the branch was still useful.
You learned something without turning the main branch into an archaeological site.
If it succeeds, open a pull request and make the change reviewable.
Branch names should describe the work:
experiment/add-promotion-lagsfix/prevent-target-leakagefeature/batch-inference-outputrefactor/split-training-pipeline
Names such as allan-test, new-model, or changes move the ambiguity into everyone else’s day.
5) Make commits small enough to trust
Large commits are where review goes to die.
A change that updates feature engineering, rewrites model training, reformats every file, regenerates a notebook, and changes deployment configuration may be logically connected in your head. It is not reviewable as one unit.
Smaller commits create checkpoints.
1. add promotion calendar loader
2. add lag feature tests
3. generate promotion lag features
4. update training configuration
This makes it easier to answer:
- Which change moved the metric?
- Which change introduced the bug?
- Which part can be reverted safely?
- Which assumption did the reviewer actually approve?
Small does not mean every line gets its own commit.
It means each commit has one coherent purpose.
Before committing, inspect the change:
git status
git diff
git diff --staged
That habit catches stray debug output, accidental data files, unrelated formatting, and the occasional API key before they become part of project history.
6) Treat notebooks as interfaces, not junk drawers
Notebooks are useful because they combine code, output, and narrative.
They are difficult in Git for the same reason.
An .ipynb file is JSON. Re-running a notebook can change execution counts, metadata, plots, and outputs even when the analytical logic barely changed. The resulting diff can hide the one line that matters.
A few practices help:
- Use notebooks for exploration, explanation, and compact analysis.
- Move reusable feature, training, and evaluation logic into
src/. - Keep notebooks restartable from top to bottom.
- Clear noisy output before committing when the output is not part of the result.
- Use a notebook-aware diff tool when notebook review is a regular part of the workflow.
The key move is moving important logic into ordinary code.
# src/features.py
def add_promotion_lags(frame, lags=(7, 14, 28)):
result = frame.copy()
for lag in lags:
result[f"promotion_lag_{lag}"] = result["promotion"].shift(lag)
return result
The notebook can call that function.
The tests can call that function.
The production pipeline can call that function.
And Git can show exactly how that function changed.
7) Connect commits to experiments
The model does not care whether your working directory was clean.
Your future self will.
Imagine two training runs:
Run A: validation RMSE = 18.4
Run B: validation RMSE = 17.9
Run B looks better. But can you answer these questions?
- Which code produced it?
- Which data snapshot did it use?
- Which parameters changed?
- Was the repository dirty during training?
- Can another person reproduce it?
A useful training run records the Git commit alongside the metrics.
git rev-parse HEAD
git status --short
The first command identifies the code revision. The second tells you whether uncommitted changes were present.
From there, your experiment tracker can record:
git_sha: 3f2a6c1
data_version: demand-2026-07-15
config: config/train.yaml
metric.rmse: 17.9
This is where Git becomes more than collaboration hygiene.
It becomes provenance.
8) Pull requests are for reasoning, not permission
The most useful data science pull requests explain more than the code.
They connect the technical change to the experimental evidence.
A good pull request might include:
## What changed
Added 7-, 14-, and 28-day promotion lag features.
## Why
The baseline under-forecasted demand following multi-week promotions.
## Evidence
- Backtest RMSE: 18.4 → 17.9
- No regression in non-promoted categories
- Feature leakage test added
## Risk
Lag availability depends on the promotion feed arriving before scoring.
Now the reviewer can evaluate the decision.
They can ask whether the backtest window is representative, whether the feature exists at prediction time, and whether the operational dependency is acceptable.
That is much more valuable than approving a thousand-line diff with “looks good.”
9) Recover without making the problem worse
Git is valuable partly because mistakes are normal.
The recovery command depends on what happened.
You changed a file but have not committed it
First inspect the change:
git diff src/features.py
If you are certain you want to discard the uncommitted changes to that file:
git restore src/features.py
That discard is not recorded as a commit, so inspect before you restore.
You committed a bad change on a shared branch
Create a new commit that reverses it:
git log --oneline
git revert <commit-sha>
git revert preserves the history. The team can see the original decision and the reversal.
You need one file from an earlier revision
git restore --source <commit-sha> -- config/train.yaml
Review the restored file, then commit it as a new change.
Be cautious with commands that rewrite history or destroy working-tree changes. A clean graph is not worth deleting work or surprising everyone sharing a branch.
10) The minimum workflow that scales
You do not need an elaborate branching strategy to get value from Git.
For most data science teams, this loop is enough:
# Start from the current main branch
git switch main
git pull --ff-only
# Create a focused branch
git switch -c experiment/add-promotion-lags
# Work, inspect, and test
git status
git diff
pytest
# Commit one coherent decision
git add src/features.py tests/test_features.py config/train.yaml
git diff --staged
git commit -m "add promotion lag features"
# Share it for review
git push -u origin experiment/add-promotion-lags
Then open a pull request that explains the problem, evidence, risk, and reproducibility details.
That is the workflow.
Branch. Change. Inspect. Test. Commit. Review. Merge.
Not glamorous.
Extremely useful.
Closing thought
Machine learning projects create more uncertainty than ordinary software projects.
The data changes. The metrics move. Experiments fail. Assumptions expire. A tiny feature change can invalidate weeks of comparison. A model that looked great offline can fail when the production inputs arrive differently.
Git does not remove that uncertainty.
It makes the uncertainty legible.
It gives the team a shared record of the decisions behind the system. It lets you isolate an idea, compare it with a baseline, review the evidence, and reverse course without pretending the failed path never happened.
The mature data science workflow is not the one that never makes mistakes.
It is the one that can explain exactly what changed, reproduce what happened, and recover without guessing.