Git Submodules
A Git submodule is a full Git repository embedded inside another Git repository. The outer repo (the superproject) records a pointer to a specific commit in the inner repo (the submodule). This lets you track an external codebase at a precise version while keeping it physically separate from your own history.
Why submodules exist
Imagine you maintain a design-system library used by a dozen product repos. You want each product to pin to a tested version of the library, see its source during development, and be able to contribute changes back upstream. A submodule thread ties the two repos together at the Git level — no publishing to a package registry required.
Shared libraries: reusable component or utility repos that multiple projects depend on.
Vendored dependencies: third-party code you want to keep alongside your source (and possibly patch).
Monorepo escape hatch: keep a sub-team's repo independent while still nesting it in a larger project.
Documentation or asset repos: large binary assets tracked separately to keep the main repo lean.
Adding a submodule
# Add a submodule at the path "libs/ui" git submodule add https://github.com/example/ui-components.git libs/ui # Add and place it at a custom path git submodule add git@github.com:example/auth-sdk.git vendor/auth
What happens on disk
Cloning into '/your/project/libs/ui'... remote: Enumerating objects: 412, done. ... done. Two new things in your repo: .gitmodules (new file tracking submodule config) libs/ui (the checked-out submodule directory)
After running git submodule add, stage and commit the changes to record the submodule in your superproject:
git status # Changes to be committed: # new file: .gitmodules # new file: libs/ui git commit -m "chore: add ui-components as submodule"
The .gitmodules file
.gitmodules is a plain-text INI file committed to the repo. It records the URL and local path for every submodule. Git reads this file to know where to clone each submodule.
.gitmodules
[submodule "libs/ui"] path = libs/ui url = https://github.com/example/ui-components.git [submodule "vendor/auth"] path = vendor/auth url = git@github.com:example/auth-sdk.git
Cloning a repo that has submodules
A plain git clone gives you the submodule directories but leaves them empty. You then need to initialise and populate them.
# Option A — all-in-one (recommended) git clone --recurse-submodules https://github.com/example/myproject.git # Option B — if you already cloned without the flag git clone https://github.com/example/myproject.git cd myproject git submodule init # registers submodules from .gitmodules into .git/config git submodule update # clones the submodule repos and checks out the pinned commit # Shortcut for init + update in one step git submodule update --init # Also init/update nested submodules (submodules of submodules) git submodule update --init --recursive
Terminal output after update --init
Submodule 'libs/ui' (https://github.com/example/ui-components.git) registered for path 'libs/ui' Cloning into '/your/project/libs/ui'... remote: Enumerating objects: 412, done. Submodule path 'libs/ui': checked out 'a3f7e91c...'
The detached HEAD in a submodule
After git submodule update, the submodule is in a detached HEAD state — it points to a specific commit, not a branch. This is intentional: the superproject pins an exact commit to guarantee reproducible builds.
cd libs/ui git status # HEAD detached at a3f7e91 # To work on the submodule (make commits to it), check out a branch first git checkout main # now edit, commit, push as normal inside libs/ui
Updating a submodule to the latest upstream commit
# Fetch and merge the remote tracking branch for all submodules git submodule update --remote # Update only one specific submodule git submodule update --remote libs/ui # --merge integrates the fetched commit rather than leaving a detached HEAD git submodule update --remote --merge libs/ui
After --remote updates the submodule, the superproject sees a modified submodule pointer. You must commit that change to record the new pinned commit:
git diff --submodule # Submodule libs/ui a3f7e91..b8c2d34: # > feat: new Button variant # > fix: tooltip z-index git add libs/ui git commit -m "chore: bump ui-components to b8c2d34"
Making changes inside a submodule and pushing
# 1. Enter the submodule and check out a branch cd libs/ui git checkout -b fix/button-focus # 2. Make and commit your changes vim src/Button.tsx git add src/Button.tsx git commit -m "fix: restore focus ring on Button" # 3. Push the submodule changes to ITS remote git push origin fix/button-focus # 4. Go back to the superproject and record the new commit pointer cd ../.. git add libs/ui git commit -m "chore: point ui-components at button focus fix"
Removing a submodule
Git has no single git submodule remove command. You must manually clean up in four places:
Delete the submodule entry from
.gitmodules.Delete the submodule entry from
.git/config.Remove the submodule directory from the working tree:
git rm --cached libs/ui.Delete the actual submodule directory data:
rm -rf libs/uiandrm -rf .git/modules/libs/ui.
Full removal sequence
# Step 1 — unstage and remove from index git rm --cached libs/ui # Step 2 — remove the working tree directory rm -rf libs/ui # Step 3 — remove residual Git data rm -rf .git/modules/libs/ui # Step 4 — remove the [submodule "libs/ui"] block from .gitmodules manually # (or use git config --file .gitmodules --remove-section submodule.libs/ui) git config --file .gitmodules --remove-section submodule.libs/ui # Step 5 — commit everything git add .gitmodules git commit -m "chore: remove libs/ui submodule"
Useful submodule commands reference
Command | What it does |
|---|---|
| Add a new submodule. |
| Register submodules from .gitmodules into .git/config. |
| Check out the commit recorded by the superproject. |
| Init + update all submodules, including nested ones. |
| Fetch and check out the tip of the tracking branch. |
| Run a shell command in every submodule directory. |
| Show the current SHA, path, and dirty state of each submodule. |
| Update local submodule URLs from .gitmodules (after URL change). |
| Clone and initialise all submodules in one shot. |
Common pitfalls
Forgetting to push the submodule before the superproject — causes others to fetch a missing object.
Committing a dirty submodule pointer —
git statusin the superproject shows a "modified" submodule; always commit afterupdate --remote.Not using
--recurse-submoduleson clone — leaves empty submodule directories that look like they exist but contain nothing.Editing inside a detached HEAD — commits made in a detached HEAD are lost if you
git submodule updateagain without first checking out a branch and pushing.Nested submodules — submodules inside submodules are supported but multiply the complexity; prefer flatter structures.
Submodules vs git subtree vs package managers
Feature | Submodules | git subtree | Package Manager (npm/pip/etc.) |
|---|---|---|---|
Source visible in repo | Yes (nested repo) | Yes (inlined into history) | No (downloaded at build time) |
Separate history | Yes, fully independent | Merged into parent history | N/A |
Contribute changes back | Easy (push from submodule dir) | Possible but awkward ( | Requires fork + PR to upstream |
Pins to exact version | Yes (commit SHA) | Yes (squash commit) | Yes (lockfile) |
Cloning complexity | Requires | None — plain clone | Requires install step |
Offline builds | Yes (already cloned) | Yes | No (needs registry) |
Binary dependencies | Poor fit | Poor fit | Excellent |
Overhead in large monorepos | High | Medium | Low |