This is part one of three. It covers everything you need to do real work with DVC, not a teaser. By the end you can version a dataset, reproduce a pipeline, share data with a colleague through remote storage, compare experiment metrics, and answer "which data produced this model?" with a commit hash. Mid-level and Senior take the same topics further; nothing here is thrown away.
Each section ends with a Try it task. Do them as you go. They take a few minutes each, and these ideas only stick once you have watched dvc repro skip a stage it did not need to run.
The problem DVC solves: code in Git, data elsewhere
Git is excellent at versioning text and hopeless at versioning data. Commit a 2 GB training set and you have permanently added 2 GB to every clone of that repository, forever, because Git stores full history. Change one row and you add another 2 GB.
So teams do the obvious thing and keep data out of Git. Then the real problem starts: the model in production was trained on something, and nobody can say what. The notebook says data/train_v3_final_fixed.csv, that file is on a laptop that was reimaged, and the person who made it left.
DVC's central trick is to store a small text pointer in Git and the actual bytes somewhere cheap. The pointer file is a few hundred bytes and contains a content hash. Git versions the pointer; DVC moves the bytes. One git checkout then brings back the exact code and tells DVC which data belongs to it.
That single idea gives you four things, and they are the reason DVC exists rather than a bespoke script:
Data tied to commits
Every commit records which dataset and which model it used. "Which data produced this?" becomes a lookup.
Reproducible pipelines
Declare stages and dependencies once. DVC reruns only what changed.
Shared storage
Push data to S3, GCS, Azure, or an SSH box. A colleague clones and pulls, and has your exact files.
Comparable experiments
Metrics and parameters are versioned alongside the code, so comparing two runs is a command, not archaeology.
You need little to follow along: Python, Git, and a folder. A tiny CSV and a five-line training script are the best place to start, because a broken pipeline costs you nothing.
- Find a project of yours with data in it. Run
git count-objects -vHand note thesize-pack. - Look for files matching
*_final*,*_v2*,*_backup*, or a date in the name. Count them. - Pick the newest model artifact you have and try to name, precisely, the exact file that trained it.
Install and initialise
DVC is a Python package, and it sits on top of Git rather than replacing it.
pip install "dvc[s3]" # or dvc[gs], dvc[azure], dvc[ssh], dvc[all]
dvc --version
The bracketed extra pulls in the driver for your remote storage. Plain pip install dvc works fine for local experimentation and then fails with a confusing "URL is not supported" the moment you configure an S3 remote, so install the extra you need up front.
git init # DVC needs a Git repository
dvc init
git status
dvc init creates a small amount of scaffolding and stages it for you:
| Path | Purpose |
|---|---|
.dvc/config |
Your project's DVC configuration: remotes, cache settings. Committed |
.dvc/.gitignore |
Keeps DVC's internal cache and temp files out of Git |
.dvcignore |
Optional; excludes paths from DVC's own scanning, like .gitignore |
.dvc/cache/ |
Where content lands locally, addressed by hash. Not committed |
git commit -m "Initialise DVC"
git add this?", the answer is almost always yes for anything DVC creates that is not inside .dvc/cache, and DVC tells you the exact command to run.
- Create a fresh folder, run
git init, thendvc init. - Run
git statusand read what DVC staged for you. - Open
.dvc/.gitignoreand note that/cacheis listed. - Commit, then run
dvc doctorand read the output.
.gitignore that already excludes the cache. dvc doctor prints your platform, version, and which remote drivers are available, worth knowing about now because it is the first thing to run when something behaves oddly.
Your first tracked dataset, in four commands
This is the core loop. Four commands, and afterwards your data is versioned.
mkdir -p data
# put a file in data/raw.csv — any size, even 10 rows
dvc add data/raw.csv
git add data/raw.csv.dvc data/.gitignore
git commit -m "Track the raw dataset"
Now look at what happened, because this is the whole mental model:
cat data/raw.csv.dvc
outs:
- md5: 8f2c4b19e0a7d3f1c6b8a2e4d7091f3b
size: 1048576
hash: md5
path: raw.csv
The .dvc file holds a hash, a size, and a path. The file itself is not in Git. data/.gitignore now contains /raw.csv, which DVC wrote for you, so Git will never try to store it.
cat data/.gitignore # /raw.csv
git status # clean; the CSV is ignored
The bytes went into DVC's cache, filed under that hash:
ls .dvc/cache/files/md5/8f/2c4b19e0a7d3f1c6b8a2e4d7091f3b
| Command | Does |
|---|---|
dvc add PATH |
Hash the file, move it to the cache, write a .dvc pointer, add to .gitignore |
dvc status |
Compare working files against the pointers |
dvc checkout |
Restore working files from the cache to match the pointers |
dvc remove PATH.dvc |
Stop tracking; removes the pointer and the .gitignore entry |
git add the data file itself
If you ever see the data file appear in git status, something is wrong, usually a missing or edited .gitignore entry. Committing the file to Git as well as DVC defeats the whole purpose and doubles your storage. git status being clean after dvc add is the signal that it worked.
- Create
data/raw.csvwith a handful of rows and rundvc add data/raw.csv. - Read the three things DVC changed: the new
.dvcfile, the new.gitignoreentry, and the cache directory. - Commit the pointer and confirm
git statusis clean. - Now check the sizes:
du -h data/raw.csv .dvc/cacheandwc -c data/raw.csv.dvc.
Inside the cache: content addressing and links
Understanding this saves you from a whole category of confusion later, and it takes two minutes.
DVC's cache is content-addressable: a file's location is derived from the hash of its contents. Two identical files, whatever they are called, occupy one cache entry. Change one byte and you get a different hash and a new entry.
dvc add data/raw.csv # hash A
echo "one more row" >> data/raw.csv
dvc add data/raw.csv # hash B — a new entry; A is still there
That has an important consequence: the cache accumulates. Every version you have ever added is still on disk until you clean it up, which is what makes going back in time possible, and also why the cache grows.
By default DVC does not copy the file into your workspace, it links it from the cache, so you are not storing two copies:
| Link type | Behaviour | Default on |
|---|---|---|
reflink |
Copy-on-write; fast, safe, and space-efficient | APFS, Btrfs, XFS where supported |
copy |
A real second copy. Safe, uses double the space | The fallback everywhere |
hardlink / symlink |
One inode, no extra space, but editing in place corrupts the cache | Opt-in |
dvc config cache.type reflink,copy # try reflink, fall back to a copy
dvc config core.check_link_support true
reflink,copy is the safe default and what you want unless you have measured a reason otherwise.
- Run
dvc addon a file, note the md5 in the.dvcfile, then append one line anddvc addagain. - Compare the two hashes, then look in
.dvc/cache/files/md5/and confirm both entries exist. - Copy the file to a second name and
dvc addthat too. Check whether the cache grew. - Run
dvc config cache.typeand thendvc doctorto see which link types your filesystem supports.
Going back in time: two commands, in order
This is the payoff. Do it on purpose early so you trust it.
# Make a second version and commit it
echo "new rows" >> data/raw.csv
dvc add data/raw.csv
git commit -am "Dataset v2"
# Travel back
git checkout HEAD~1 data/raw.csv.dvc
dvc checkout data/raw.csv
head data/raw.csv # v1 is back
Two commands, and the order matters. git checkout moves the pointer; dvc checkout makes the working file match it. Miss the second step and you have v1's pointer with v2's file on disk, which dvc status will tell you about immediately.
dvc status
# data/raw.csv.dvc:
# changed outs:
# modified: data/raw.csv
git checkout
- Moves code and
.dvcpointers - Instant, because pointers are tiny
- Leaves data files untouched
dvc checkout
- Moves data files to match the pointers
- Reads from the local cache
- Fails if the cache does not have that version, so then you need
dvc pull
dvc install adds Git hooks that run dvc checkout after git checkout, and warn you about unpushed data before git push. It removes the most common source of "why is my data the wrong version?" A forgotten second command.
- Create three versions of a file, committing after each
dvc add. - Use
git log --onelineto find the first commit, thengit checkout <sha> data/raw.csv.dvc. - Run
dvc statusbeforedvc checkoutand read what it reports. - Run
dvc checkout, confirm the file matches v1, then return to the latest withgit checkout HEAD data/raw.csv.dvc && dvc checkout. - Now run
dvc installand repeat step two, and notice you no longer need step four.
dvc status names precisely. Seeing that state once means you will recognise it instantly, and dvc install means you rarely have to.
Remote storage: where the bytes live
So far everything lives on your machine. A remote is where the bytes go so a colleague (or a CI runner, or a training box) can get them.
# Local folder: perfect for learning, and genuinely useful for a shared NAS
dvc remote add -d myremote /tmp/dvc-storage
# S3
dvc remote add -d storage s3://my-bucket/dvc-store
# Google Cloud Storage
dvc remote add -d storage gs://my-bucket/dvc-store
# Azure Blob
dvc remote add -d storage azure://my-container/dvc-store
# Any SSH box
dvc remote add -d storage ssh://user@host/srv/dvc-store
git add .dvc/config
git commit -m "Configure the default DVC remote"
The -d makes it the default, so dvc push and dvc pull need no arguments. The configuration lands in .dvc/config, which is committed. It contains the location, not the credentials.
dvc push # upload cached content to the remote
dvc pull # download what the current pointers need
dvc fetch # download to the cache without touching the workspace
dvc status -c # compare local cache against the remote
Those four are the whole workflow, and the mental model mirrors Git exactly:
dvc.yamlScripts, stages, params*.dvc · dvc.lockHash, size, path: a few hundred bytes.dvc/cacheContent-addressed, local, gitignoredfiles/md5/8f/2c4b…, the same layout in cache and remotedvc add · checkoutWorkspace ↔ cachedvc push · pull · fetchCache ↔ remotedvc gc --cloudDeletes on the remote. Not recoverableRead it as two hops: a pointer in Git names a hash, the cache holds that hash locally, and the remote holds it for everyone else. git push ships the pointer; dvc push ships the bytes. Do one without the other and a colleague gets a reference to data they cannot fetch.
| Git | DVC | Moves |
|---|---|---|
git add |
dvc add |
Working file → tracked |
git commit |
(commit the .dvc file) |
Pointer → history |
git push |
dvc push |
Cache → remote |
git pull |
dvc pull |
Remote → cache → workspace |
git status |
dvc status / dvc status -c |
What differs, locally / against the remote |
.dvc/config
That file is committed, so a secret in it is a secret in your history. Use the cloud provider's normal mechanism: AWS_ACCESS_KEY_ID in the environment, an IAM role, gcloud auth, an SSH agent. If you must store something per-machine, DVC has a local config for exactly that, covered in the next section.
- Add a local-folder remote pointing at
/tmp/dvc-storageand commit.dvc/config. - Run
dvc push, then look inside/tmp/dvc-storagefor the same hash-based layout as the cache. - Run
dvc status -cand confirm everything is up to date. - Now simulate a colleague:
rm -rf .dvc/cache data/raw.csv, thendvc pull.
Two config files: committed versus local
There are two config files, and knowing which is which prevents both leaked secrets and "it works on my machine".
dvc remote add -d storage s3://my-bucket/dvc-store # → .dvc/config, committed
dvc remote modify --local storage access_key_id AKIA… # → .dvc/config.local, ignored
| File | Committed | For |
|---|---|---|
.dvc/config |
Yes | Remote URLs, cache type, anything the whole team shares |
.dvc/config.local |
No (gitignored) | Credentials, machine-specific paths, a personal cache directory |
The pattern that follows from this is worth adopting immediately: shared truth in config, personal reality in config.local. A remote URL is shared truth. Your access key is personal reality. So is a cache on a different disk because your home directory is small.
# Useful local-only settings
dvc cache dir /mnt/big-disk/dvc-cache # move the cache off a small root disk
dvc remote modify --local storage ssh_private_key_path ~/.ssh/id_dvc
dvc config --local core.jobs 8 # more parallel transfers on a fast link
pull but must not push. Access control lives in the storage provider, not in DVC, but declaring the remote in .dvc/config means everyone at least agrees on where the data is.
- Run
dvc remote listand thencat .dvc/config. - Set something local-only:
dvc config --local core.jobs 4, thencat .dvc/config.local. - Run
git statusand confirmconfig.localis not listed. - Check
.dvc/.gitignoreand find the entry that makes that true.
Tracking directories, and the .dir object
Datasets are rarely one file. dvc add works on directories, and the behaviour is slightly different in a way worth knowing.
dvc add data/images/ # one .dvc file for the whole directory
cat data/images.dvc
outs:
- md5: 3a7f9c2e8b1d4506.dir # note the .dir suffix
size: 524288000
nfiles: 12000
hash: md5
path: images
The .dir suffix marks this as a directory hash, the hash of a small JSON listing that maps every file in the tree to its own hash. Individual files are still deduplicated in the cache, so adding a directory where one image changed uploads exactly one new object.
| Property | Single file | Directory |
|---|---|---|
| Pointer records | One hash | A .dir hash plus nfiles |
| Cache entries | One | One per file, plus the listing |
| Changing one file | New hash for the file | New .dir hash, one new file entry |
dvc status granularity |
The file | Counts of added, modified, deleted |
dvc add on a tree of 500,000 tiny images takes a long time and produces a large listing. If that is your situation, the answer is usually to package the files (tar shards, Parquet, WebDataset, LMDB) which is faster for DVC and faster for your data loader. Mid level covers this properly.
- Create
data/images/with twenty small files and rundvc add data/images. - Read the
.dvcfile and note the.dirhash andnfiles. - Change one file, run
dvc status, and read how it reports the difference. dvc addagain anddvc push, then count the objects that were uploaded.
Pipelines: from dvc add to dvc.yaml
dvc add versions data somebody produced. A pipeline versions the process, which is what makes a result reproducible rather than recorded.
The idea is a dependency graph. Each stage declares what it needs and what it makes, and DVC reruns a stage only when one of its declared inputs changed.
stages:
prepare:
cmd: python src/prepare.py data/raw.csv data/prepared.csv
deps:
- src/prepare.py
- data/raw.csv
outs:
- data/prepared.csv
train:
cmd: python src/train.py data/prepared.csv models/model.pkl
deps:
- src/train.py
- data/prepared.csv
outs:
- models/model.pkl
dvc repro # run whatever is out of date, in dependency order
git add dvc.yaml dvc.lock .gitignore
git commit -m "Add the prepare → train pipeline"
Four keys carry almost all of the meaning:
| Key | Means |
|---|---|
cmd |
The command to run. Any executable: Python, R, a shell script, a binary |
deps |
Inputs. If any changes, this stage is out of date |
outs |
Outputs. DVC tracks these, caches them, and gitignores them for you |
params |
Values read from params.yaml, so a config change invalidates the stage |
Notice what you no longer do: you do not dvc add a pipeline output. outs already tracks it. Running dvc add on something a stage produces is a common early mistake and DVC will refuse it, because two mechanisms would then own the same file.
dvc.yaml by hand
There is a dvc stage add command that generates it, and it is fine. But dvc.yaml is a short, readable file that you will edit far more often than you create, so learning to write it directly is faster within about ten minutes.
- Write two tiny scripts (one that reads a CSV and writes another, one that reads that and writes a text file) and the
dvc.yamlabove to match. - Run
dvc reproand watch both stages execute in order. - Run
dvc reproagain with no changes and read the output. - Try to
dvc addone of the outputs and read the error.
dvc add attempt is refused because the file is already a stage output. Those two behaviours are the pipeline model in miniature.
dvc.lock, and how DVC knows what changed
dvc.yaml is what you wrote. dvc.lock is what happened, and it is the file that makes reproducibility real.
schema: '2.0'
stages:
prepare:
cmd: python src/prepare.py data/raw.csv data/prepared.csv
deps:
- path: data/raw.csv
hash: md5
md5: 8f2c4b19e0a7d3f1c6b8a2e4d7091f3b
size: 1048576
- path: src/prepare.py
hash: md5
md5: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d
size: 842
outs:
- path: data/prepared.csv
hash: md5
md5: 9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b
size: 987654
Every dependency and output is recorded with its exact hash at the moment the stage last ran successfully. That is how dvc repro decides what to do. It hashes the current inputs and compares them against the lock.
deps and outs across all stages.dvc.lock.cmd.dvc.lock
It is the record of which exact inputs produced which exact outputs. Without it, a colleague running dvc repro rebuilds everything and has no way to verify they got the same result. Treat it exactly like a package-lock.json: generated, never hand-edited, always committed.
Two more commands make the graph legible:
dvc dag # ASCII dependency graph
dvc dag --outs # the same, keyed on outputs
dvc status # which stages are out of date, and why
- Run
dvc repro, then opendvc.lockand find the hash ofdata/raw.csv. - Compare it with the hash in
data/raw.csv.dvc. They match. - Edit one comment in
src/train.pyand rundvc status. Onlytrainis out of date. - Now edit
src/prepare.pyinstead and rundvc statusagain. both stages are affected. - Run
dvc dagand check the picture matches your expectation.
Parameters: config DVC can see
Hard-coded hyperparameters make a stage un-reproducible: you changed n_estimators from 100 to 200, DVC saw no dependency change, and skipped the stage. params fixes that.
prepare:
test_size: 0.2
random_state: 42
train:
n_estimators: 100
max_depth: 8
learning_rate: 0.05
stages:
train:
cmd: python src/train.py
deps:
- src/train.py
- data/prepared.csv
params:
- train.n_estimators
- train.max_depth
- train.learning_rate
outs:
- models/model.pkl
import yaml
with open("params.yaml") as f:
params = yaml.safe_load(f)["train"]
model = RandomForestClassifier(
n_estimators=params["n_estimators"],
max_depth=params["max_depth"],
)
Now changing a number in params.yaml marks the stage out of date, as changing the code would. You can list individual keys as above, or - train to depend on the whole section.
dvc params diff # what changed since the last commit
dvc params diff HEAD~3 # or against any revision
params.yaml: learning rates, thresholds, feature flags, split ratios. Anything structural belongs in code. Getting this line in the right place is what makes experiment tracking pleasant later, because DVC can then vary parameters without touching a single source file.
- Move one hard-coded number from your training script into
params.yamland declare it indvc.yaml. - Run
dvc repro, then run it again and confirm the stage is skipped. - Change the value in
params.yamland rundvc status. The stage is out of date, with the parameter named. - Run
dvc repro, thendvc params diff.
dvc status is what makes a long pipeline debuggable.
Metrics and plots: results that diff in Git
A pipeline that produces a model but no numbers is a pipeline you cannot reason about. DVC has two output kinds for exactly this.
stages:
evaluate:
cmd: python src/evaluate.py
deps:
- src/evaluate.py
- models/model.pkl
- data/test.csv
metrics:
- metrics.json:
cache: false
plots:
- plots/roc.json:
cache: false
x: fpr
y: tpr
- plots/confusion.csv:
cache: false
template: confusion
x: actual
y: predicted
{
"accuracy": 0.9231,
"f1": 0.9104,
"auc": 0.9687,
"train_seconds": 42.7
}
dvc metrics show # the current numbers
dvc metrics diff # versus the last commit
dvc metrics diff HEAD~5 # versus any revision
dvc plots show # render plots to an HTML file
dvc plots diff HEAD~1 # overlay two revisions on one chart
The cache: false on those outputs is deliberate and worth understanding: metrics files are small text, so committing them to Git rather than caching them in DVC means dvc metrics diff can compare any two commits without downloading anything.
| Output kind | Tracked by | Use for |
|---|---|---|
outs |
DVC cache | Models, datasets, anything large or binary |
metrics with cache: false |
Git | Small JSON/YAML of scores |
plots with cache: false |
Git | Small CSV/JSON of curves and confusion matrices |
outs with cache: false |
Git | Any small text output you want diffable |
metrics.json in the DVC cache means comparing two commits requires a dvc pull from the remote. The same file in Git is instantly diffable, forever, by anyone with the repository. Reserve the cache for things Git is bad at.
- Add an
evaluatestage that writes ametrics.jsonwith two or three numbers. - Run
dvc repro, commit, then change a parameter and repro again. - Run
dvc metrics diffand read the before/after/change columns. - Add a plot output, a CSV of two columns is enough, and run
dvc plots diff HEAD~1, then open the HTML it produces.
Experiments: cheap runs, no commits
Committing every attempt pollutes your history. dvc exp gives you a lightweight way to run many variations, compare them, and keep only the ones worth keeping.
# Run with a parameter override, without editing any file
dvc exp run --set-param train.n_estimators=300
# Queue several and run them together
dvc exp run --queue --set-param train.max_depth=4
dvc exp run --queue --set-param train.max_depth=8
dvc exp run --queue --set-param train.max_depth=16
dvc exp run --run-all --jobs 3
# Compare
dvc exp show
dvc exp show --only-changed # hide columns that are identical everywhere
dvc exp show prints a table of every experiment with its parameters and metrics side by side, which is the fastest way to see that max_depth=8 beat both neighbours.
───────────────────────────────────────────────────────────────
Experiment accuracy f1 max_depth n_estimators
───────────────────────────────────────────────────────────────
workspace 0.9231 0.9104 8 100
main 0.9105 0.8977 6 100
├── exp-a1b2c 0.9402 0.9311 8 300
├── exp-d4e5f 0.9188 0.9042 4 100
└── exp-g7h8i 0.9377 0.9265 16 100
───────────────────────────────────────────────────────────────
Then promote the winner and discard the rest:
dvc exp apply exp-a1b2c # bring it into your workspace
git add . && git commit -m "n_estimators=300: accuracy 0.940"
dvc exp branch exp-a1b2c my-branch # or put it on its own branch
dvc exp remove --queued # clean up
dvc exp gc --workspace # garbage-collect experiments not referenced
An experiment
- No commit needed, no branch needed
- Cheap to create and to throw away
- Comparable in one table
- Promote with
applyorbranch
A commit per attempt
- Pollutes history with dead ends
- Comparing means reading log messages
- Rebasing later becomes unpleasant
- Nobody can find the good one
dvc exp run works from your current state, so uncommitted code changes are included, which is usually what you want while iterating. But it means two experiments run at different times can differ by code you forgot about. Commit the code, vary the parameters.
- Queue three experiments varying one parameter, then run them all with
--run-all. - Run
dvc exp show --only-changedand identify the best result. - Apply the winner with
dvc exp apply, then checkgit statusandparams.yaml. - Commit it, then run
dvc exp remove --queuedanddvc exp showagain.
Reading another repo's data: get versus import
Two commands let you use a DVC repository as a data source, which is how datasets get shared across projects.
# Look at what a repo tracks, without cloning it
dvc list https://github.com/iterative/dataset-registry get-started
# Download a file from it into the current directory
dvc get https://github.com/iterative/dataset-registry get-started/data.xml
# Download AND record where it came from
dvc import https://github.com/iterative/dataset-registry get-started/data.xml
The difference between the last two is the whole point:
dvc get |
dvc import |
|
|---|---|---|
| Downloads the file | Yes | Yes |
| Records the source | No | Yes, in a .dvc file |
| Can be updated later | No: download again by hand | dvc update |
| Knows the source revision | No | Yes, pinned to a commit |
dvc import --rev v1.2.0 https://github.com/org/data-repo datasets/train.parquet
dvc update datasets/train.parquet.dvc # pull the newer upstream version
dvc import writes a pointer that names the source repository, path, and revision. That means your project can state "we depend on version v1.2.0 of the shared training set" as a committed fact, and updating is a deliberate command rather than a silent drift.
dvc import from it. There is nothing special about it. It is the pattern that emerges naturally once two projects need the same data, and Senior level covers running one properly.
- Run
dvc list https://github.com/iterative/dataset-registryand explore what is there. dvc geta file from it and note thatgit statusshows an untracked file with no pointer.- Delete it, then
dvc importthe same file and read the.dvcfile it creates. - Find the
repo:section, and therev_lockthat pins the exact upstream commit.
get leaves you with an anonymous file, while import leaves you with a versioned dependency that names its source and revision. That distinction is the difference between copying data and depending on it.
Garbage collection: reclaiming the cache safely
The cache accumulates every version of everything you have ever added. That is the feature; it is also why your disk fills up.
dvc gc --workspace # keep only what the current workspace needs
dvc gc --all-branches # keep what every branch needs
dvc gc --all-tags # keep what every tag needs
dvc gc --all-commits # keep what every commit needs — the safest
dvc gc --cloud # also clean the remote (dangerous, read below)
dvc gc --dry-run --workspace # show what would go, delete nothing
The flags are all about which revisions count as "in use". The default, no flag at all, is --workspace, which is the most aggressive: anything not needed by the files currently checked out is deleted.
dvc gc deletes data, and --cloud deletes shared data
dvc gc --workspace on a repository with ten branches will delete the cached data for nine of them. Add --cloud and you have done it to your team's remote as well, where it is not recoverable. Always run with --dry-run first, and prefer --all-commits unless you have a specific reason to be aggressive.
Two other space commands worth knowing:
du -sh .dvc/cache # how big is the local cache
dvc cache dir # where is it
dvc cache dir /mnt/big/cache # move it, e.g. off a small root disk
dvc cache dir --local /shared/dvc-cache. Content-addressing means identical files are stored once for all of them. Use the --local flag so the path stays out of the committed config.
- Create three versions of a dataset, committing each, so the cache has three entries.
- Run
du -sh .dvc/cacheand note the size. - Run
dvc gc --dry-run --workspaceand read what it proposes to delete. - Now run
dvc gc --dry-run --all-commitsand compare the two lists.
--workspace proposes deleting the two older versions; --all-commits proposes deleting nothing, because every version is still reachable from a commit. Understanding that difference before you run it for real is the point of this exercise.
Reading a broken pipeline, in order
Debugging DVC has an order, and following it beats guessing.
- Run
dvc statusfirstIt names the stage, the specific dependency, and the reason. Most problems are answered here and you can stop. - Then
dvc status -cCompares your cache against the remote. This is the answer to "my colleague cannot pull my data". You never pushed it. - Check the graph with
dvc dagA stage that never reruns often has a missingdepsentry; a cycle error means two stages each claim the other's output. - Run the
cmdby handCopy the command out ofdvc.yamland run it in your shell. If it fails there, DVC is innocent and you are debugging your script. - Force a rerun with
dvc repro -fIgnores the lock and reruns everything. If forcing fixes it, yourdepsare incomplete, and the real fix is declaring the missing dependency. - Add
-vfor verbose outputdvc repro -vordvc pull -vprints what DVC is doing, including which remote it contacted and why a transfer failed.
Four failures cover most of what you will hit at this level, and each has a one-line cause:
| Symptom | Cause | Fix |
|---|---|---|
ERROR: output 'x' is already tracked |
You dvc added a pipeline output |
Remove the .dvc file; outs already tracks it |
| Stage never reruns after a code change | The script is not in deps |
Add it |
dvc pull says "missing cache files" |
The data was never pushed | dvc push from wherever it was produced |
Data is the wrong version after git checkout |
You skipped dvc checkout |
Run it, or dvc install the hooks |
dvc status # local truth
dvc status -c # remote truth
dvc dag # the graph as DVC sees it
dvc repro -f # rebuild everything, ignoring the lock
dvc repro --dry # show what would run, run nothing
dvc repro -s train # just one stage, and its upstream
dvc repro -s train --single-item # exactly one stage, no upstream
- Remove a script from a stage's
deps, change the script, and confirmdvc reproskips the stage. - Try to
dvc adda pipeline output and read the exact error text. - Delete your local cache and try
dvc checkoutwithout having pushed. Read the error, thendvc pull. - For each one, get to the answer with
dvc statusrather than by remembering what you broke.
deps matters more than any other habit at this level.
Putting it all together
Everything above in one project. Nothing here is new. Read it as a whole and you should be able to justify every line.
.
├── .dvc/
│ ├── config # remote URL — committed
│ └── config.local # credentials — gitignored
├── .dvcignore
├── data/
│ ├── .gitignore # written by DVC
│ ├── raw.csv.dvc # pointer, committed
│ └── raw.csv # the bytes, gitignored
├── src/
│ ├── prepare.py
│ ├── train.py
│ └── evaluate.py
├── models/
│ └── .gitignore # written by DVC
├── params.yaml # committed
├── metrics.json # committed (cache: false)
├── plots/roc.csv # committed (cache: false)
├── dvc.yaml # committed
└── dvc.lock # committed
prepare:
test_size: 0.2
random_state: 42
train:
n_estimators: 300
max_depth: 8
stages:
prepare:
cmd: python src/prepare.py
deps:
- src/prepare.py
- data/raw.csv # the dvc add-tracked input
params:
- prepare.test_size
- prepare.random_state
outs:
- data/train.csv # cached: large, binary-ish, regenerable
- data/test.csv
train:
cmd: python src/train.py
deps:
- src/train.py
- data/train.csv # produced upstream; the link in the graph
params:
- train.n_estimators
- train.max_depth
outs:
- models/model.pkl # cached: the artifact
evaluate:
cmd: python src/evaluate.py
deps:
- src/evaluate.py
- models/model.pkl
- data/test.csv
metrics:
- metrics.json:
cache: false # small text → Git, so diffs need no pull
plots:
- plots/roc.csv:
cache: false
x: fpr
y: tpr
# The everyday loop
dvc repro # run only what is out of date
dvc metrics diff # did the numbers move?
git add dvc.lock metrics.json plots/ params.yaml
git commit -m "Deeper trees: accuracy 0.921 → 0.940"
dvc push # share the data and the model
git push
# What a colleague runs
git clone <repo> && cd <repo>
dvc pull # exactly your data and model
dvc repro # "Everything is up to date" — reproduced
Ten decisions in there are the whole lesson of this page:
| Decision | Section |
|---|---|
| Data in a remote, pointers in Git | The problem DVC solves |
.dvc/config committed, config.local not |
Local versus committed configuration |
dvc add for inputs you did not generate |
Your first tracked dataset |
outs: never dvc add: for generated files |
Pipelines |
Every script listed in deps |
dvc.lock and how DVC knows |
dvc.lock committed, never edited |
dvc.lock and how DVC knows |
Hyperparameters in params.yaml |
Parameters |
cache: false on metrics and plots |
Metrics and plots |
dvc push before git push |
Remote storage |
dvc gc --dry-run before any cleanup |
Garbage collection |
- Take this structure into a project you work on, adapting the stages to your own scripts.
- Get
dvc reproto a state where a second run reports everything up to date. - Push to a real remote, then clone the repository into a different directory,
dvc pull, anddvc repro. Confirm it reports up to date rather than rebuilding. - Change one parameter, repro, and produce a
dvc metrics diffyou would be happy to paste into a pull request.
What you can now do, and what comes next
You can version datasets and models with Git-sized pointers, restore any past version, share data through remote storage, express your workflow as a dependency graph that reruns only what changed, parameterise it, record metrics and plots that diff across commits, run and compare experiments without polluting history, depend on datasets from other repositories, keep your disk under control, and debug a pipeline methodically. That is a working practitioner's toolkit, enough to own the data and reproducibility story on a real project.
| Can you… | |
|---|---|
Explain what is in a .dvc file? |
A hash, a size, and a path |
| Say why Git alone is wrong for data? | Full history of large binaries, forever |
| Name the two commands to travel back in time? | git checkout then dvc checkout |
| Say where credentials belong? | The environment or .dvc/config.local |
Explain how dvc repro decides what to run? |
Hashes in dvc.lock versus current inputs |
Say why you never dvc add a stage output? |
outs already tracks it |
| Explain why parameters need declaring? | Otherwise a config change is invisible |
Say why metrics use cache: false? |
Small text belongs in Git, so diffs need no pull |
Give the difference between get and import? |
Anonymous copy versus versioned dependency |
Say what dvc gc --workspace deletes? |
Everything not needed by the current checkout |
| Name the first command when something is wrong? | dvc status, then dvc status -c |
Mid-level takes every one of those topics further: the hashing and link internals that explain performance, dvc.yaml templating with foreach and matrix, output modifiers like persist and cache: false, external and cloud-versioned outputs, dvc import-url, multiple and per-role remotes, experiment queues and cloud-based experiments, the CI patterns that make dvc repro a pull-request check, DVCLive for in-training logging, and monorepo layouts.
Senior then covers what you own when data versioning is your responsibility: the trust and access model across remotes, credentials that are never stored, immutability and retention on the storage side, cost and lifecycle policy, hashing at terabyte scale, data registries as a product, lineage and audit for regulated work, GDPR deletion against an immutable cache, CI/CD for data with self-hosted runners, and where DVC stops and a feature store or lakehouse begins.