Git Submodules vs Git Subtree: Which to Use
Git submodules link another repo at a pinned commit; git subtree copies its files and history into yours. How each works and when to choose which.
Git submodules and git subtree are two ways to include one Git repository inside another. A submodule stores a pointer: the parent repository records the URL of the other repo and the exact commit to check out, and the files live in a separate nested repository. A subtree copies the other repo’s files, and optionally its history, directly into a folder of the parent, so they become ordinary tracked files. Submodules keep the boundary sharp but add workflow friction; subtrees are invisible to collaborators but make pushing changes back upstream more awkward.
Both solve the same problem: you depend on code that lives in its own repository and want it available at a specific version inside yours. If you’re new to how Git tracks content, start with what Git is.
How submodules work
Adding a submodule:
git submodule add https://example.com/team/shared-ui.git libs/shared-ui
git commit -m "Add shared-ui submodule"
Git does two things. It creates a .gitmodules file mapping the path libs/shared-ui to its URL, and it records a special entry in the parent’s tree called a gitlink: not the files, but the commit hash the submodule should be at.
When someone else clones the parent, the submodule folder is empty until they initialize it:
git clone --recurse-submodules https://example.com/team/app.git
# or, after a normal clone:
git submodule update --init --recursive
To move the submodule to a newer version, you go into it, check out the new commit, and then commit the updated pointer in the parent:
cd libs/shared-ui
git fetch && git checkout v2.1.0
cd ../..
git add libs/shared-ui
git commit -m "Bump shared-ui to v2.1.0"
The parent always pins an exact commit. Nothing changes until someone deliberately updates that pointer, which is a property similar to what a lockfile gives you for packages.
How subtree works
git subtree is a contributed command that ships with most Git installations. Adding a subtree:
git subtree add --prefix=libs/shared-ui https://example.com/team/shared-ui.git main --squash
This fetches the other repository and merges its content into libs/shared-ui as normal files. With --squash, the external history is collapsed into a single commit; without it, the full upstream history is merged into yours.
Pulling upstream changes later:
git subtree pull --prefix=libs/shared-ui https://example.com/team/shared-ui.git main --squash
And contributing local changes back:
git subtree push --prefix=libs/shared-ui https://example.com/team/shared-ui.git my-fix-branch
subtree push walks your history, extracts the commits that touched the prefix, rewrites them as if the prefix were the repository root, and pushes the result. On large histories this can be slow.
Crucially, anyone who clones the parent gets the subtree’s files immediately. There’s no extra command and no .gitmodules. To them it’s just a directory.
Submodules vs subtree at a glance
| Git submodules | Git subtree | |
|---|---|---|
| What the parent stores | URL plus pinned commit | The actual files (and optionally history) |
| Clone experience | Needs --recurse-submodules or update --init | Works with a plain clone |
| Repository size | Parent stays small | Parent grows with the included code |
| Updating the dependency | Check out new commit, commit the pointer | git subtree pull |
| Contributing upstream | Commit and push inside the submodule as usual | git subtree push, which splits history |
| Local edits | Easy to lose on detached HEAD | Just normal commits |
| Collaborator awareness | Everyone must know about submodules | Most can ignore it entirely |
| Access control | Can reference a repo some users can’t read | All code is in the parent |
The common submodule pitfalls
Submodules have a reputation for confusing people, and most of it comes from a few behaviors:
- Forgetting to initialize. A fresh clone without
--recurse-submodulesleaves empty folders and puzzling build errors. - Detached HEAD.
git submodule updatechecks out the pinned commit, not a branch. If you then make changes and commit inside the submodule, those commits aren’t on any branch and are easy to lose. Create or check out a branch before editing. - Pointer drift. You switch branches in the parent, but the submodule stays where it was until you run
git submodule update.git statusshows the submodule as modified, and it’s easy to accidentally commit an unintended pointer change. - Two-step pushes. Changes made inside a submodule must be pushed to the submodule’s remote before pushing the parent’s new pointer, or collaborators will get a reference to a commit that doesn’t exist on the server.
git push --recurse-submodules=checkguards against this.
Setting git config --global submodule.recurse true makes many commands, including checkout and pull, update submodules automatically, which removes a lot of this friction.
The common subtree pitfalls
Subtree trades those problems for different ones:
- Mixed commits. If a single commit touches both the subtree and the rest of the project, splitting it back out for upstream becomes messier. Keep subtree changes in their own commits.
- History bloat. Without
--squash, the parent inherits the dependency’s entire history. - Remembering the remote. Subtree doesn’t store the upstream URL anywhere; you have to supply it on every pull and push, typically by adding a named remote.
- Merge conflicts. Pulling upstream is a real merge, so heavy local modifications produce conflicts like any other merge.
When to choose each
Choose submodules when:
- The dependency is developed actively and independently, and you contribute to it often.
- You need to keep the parent repository small, or the included repo is large.
- Different consumers must pin different versions explicitly and visibly.
- Some contributors shouldn’t have access to the included code.
Choose subtree when:
- You mostly consume the dependency and rarely push changes back.
- You want a plain
git cloneto produce a working project for everyone, including CI. - You’re vendoring a small library and want it to behave like your own code.
Consider neither when the code is published as a package. A package manager with semantic versioning, covered in what semantic versioning is, usually beats both. And if the repositories are tightly coupled and change together, merging them into a monorepo may be simpler than stitching them together at all.
The takeaway
Submodules embed a pointer to another repository at a pinned commit; subtree embeds a copy of its files. Submodules keep projects separate and the parent small but require every collaborator to learn their workflow. Subtree is invisible to anyone who doesn’t need to care, at the cost of a bigger repository and clunkier upstream contributions. If the shared code can be published as a versioned package, that’s often the cleanest option of all.
Tagged
Keep reading
The Lycoris Team · · 5 min read tmux Tutorial: Sessions, Windows, and Panes for Beginners
tmux is a terminal multiplexer that keeps shells running after you disconnect and splits one terminal into panes. A hands-on beginner's tutorial.
Chisato · · 5 min read What Are Reproducible Builds? Verifiable Software, Explained
Reproducible builds produce bit-for-bit identical output from the same source, so anyone can verify a binary. Why they matter and how to achieve them.
Chisato · · 5 min read Pulumi vs Terraform: Code vs Declarative Config for IaC
Pulumi defines infrastructure in general-purpose languages like TypeScript and Python; Terraform uses its own declarative HCL. Here's how they differ.