Git Submodules: The Complete Guide for 2026
Git submodules let you embed one Git repository inside another as a dependency. They are the built-in way to share libraries, configuration, or any code between projects while keeping each repository independent. Despite their reputation for being confusing, submodules are straightforward once you understand the mental model: the parent repo stores a pointer (a specific commit SHA) to a child repo at a specific path.
This guide covers every aspect of working with submodules: adding and cloning them, updating to new versions, working with branches, committing changes, removing them cleanly, and integrating them into CI/CD pipelines. We also compare submodules against alternatives like subtrees, monorepos, and language-specific package managers so you can pick the right tool for your project.
Table of Contents
- What Are Git Submodules?
- When to Use Submodules
- Adding Submodules
- Cloning Repos with Submodules
- Updating Submodules
- .gitmodules File Explained
- Working with Submodule Branches
- Committing Changes in Submodules
- Removing Submodules Properly
- Submodules vs Subtrees vs Monorepos
- CI/CD with Submodules
- Common Pitfalls and Solutions
- Best Practices for Managing Dependencies
- Alternatives to Submodules
- FAQ
What Are Git Submodules?
A Git submodule is a reference to another Git repository at a specific commit, embedded inside a parent repository at a given path. The parent does not store the submodule's files in its own history. Instead, it records two things:
- The URL of the external repository (stored in
.gitmodules) - The exact commit SHA the submodule should be checked out at (stored in the parent's tree object)
When you look at a submodule entry in the parent repo, Git shows it as a single "commit" object rather than a tree of files:
# In the parent repository
$ git ls-tree HEAD
100644 blob a1b2c3d .gitmodules
160000 commit e4f5g6h libs/shared-utils
That 160000 mode is the special "gitlink" type, telling Git this path points to a commit in another repository. The submodule directory contains a complete Git repository with its own .git, its own branches, and its own history.
When to Use Submodules
Submodules work best when you need to:
- Share a library across multiple projects — A shared utility library, design system, or protobuf definitions used by several services
- Pin a dependency to an exact version — The parent repo locks to a specific commit, so builds are reproducible
- Keep a vendor library separate — Include a third-party library you may need to patch, without forking it into your history
- Compose a project from independent repos — Microservices teams that want one "umbrella" repo for local development
- Separate access control — The submodule repo can have different permissions than the parent
Submodules are a poor fit when contributors rarely touch the submodule code, when you need tight coupling between repos, or when the team is not comfortable with the extra Git commands. In those cases, consider the alternatives section below.
Adding Submodules
The git submodule add command registers an external repository as a submodule:
# Basic: add a submodule at the default path (repo name)
git submodule add https://github.com/your-org/shared-utils.git
# Specify a custom path
git submodule add https://github.com/your-org/shared-utils.git libs/shared-utils
# Add from a specific branch
git submodule add -b main https://github.com/your-org/shared-utils.git libs/shared-utils
This does three things:
- Clones the external repository into
libs/shared-utils - Creates (or updates) the
.gitmodulesfile with the URL and path - Stages both the
.gitmodulesfile and the submodule path for commit
Now commit to record the submodule in the parent:
git commit -m "Add shared-utils as submodule at libs/shared-utils"
After this commit, anyone who clones your repository will see the libs/shared-utils directory, but it will be empty until they initialize the submodule.
Cloning Repos with Submodules
The most common source of confusion with submodules is cloning a project and finding empty directories. By default, git clone does not initialize submodules.
Option 1: Clone with --recurse-submodules (Recommended)
# Clone and initialize all submodules in one step
git clone --recurse-submodules https://github.com/your-org/main-project.git
# This handles nested submodules too (submodules within submodules)
Option 2: Initialize After Cloning
# If you already cloned without submodules
git submodule init
git submodule update
# Or combine both steps
git submodule update --init
# For nested submodules, add --recursive
git submodule update --init --recursive
Option 3: Shallow Submodule Clone
For large submodules where you only need the current commit, use a shallow clone:
# Clone submodules with depth 1 (only the pinned commit)
git clone --recurse-submodules --shallow-submodules https://github.com/your-org/main-project.git
# Or after cloning
git submodule update --init --depth 1
This is particularly useful in CI/CD pipelines where build speed matters and you never browse submodule history.
Updating Submodules
There are two kinds of submodule updates, and confusing them is one of the most common pitfalls.
Update to the Commit Recorded by the Parent
This is what you do after pulling changes in the parent repo that updated the submodule pointer:
# Someone else updated the submodule reference; pull their changes
git pull origin main
# Now update submodules to match the new pointers
git submodule update
# With --recursive for nested submodules
git submodule update --recursive
This checks out the exact commit the parent repo specifies. It does not fetch anything new from the submodule's remote.
Update to the Latest Remote Commit
This fetches new commits from the submodule's remote and updates the checkout:
# Update a specific submodule to the latest remote commit
git submodule update --remote libs/shared-utils
# Update all submodules to their latest remote commits
git submodule update --remote
# Update and merge (instead of checkout)
git submodule update --remote --merge
# Update and rebase local changes on top
git submodule update --remote --rebase
After running --remote, the submodule is now at a newer commit than what the parent records. You need to commit this change in the parent:
# Stage the updated submodule reference
git add libs/shared-utils
git commit -m "Update shared-utils to latest version"
For more on managing branches when updating, see the Git Branching Strategies Guide.
.gitmodules File Explained
The .gitmodules file lives at the root of your repository and maps submodule paths to their remote URLs and configuration. It is a plain INI-style file tracked by Git:
[submodule "libs/shared-utils"]
path = libs/shared-utils
url = https://github.com/your-org/shared-utils.git
branch = main
[submodule "vendor/payment-sdk"]
path = vendor/payment-sdk
url = git@github.com:your-org/payment-sdk.git
branch = stable
shallow = true
[submodule "tools/linter-config"]
path = tools/linter-config
url = https://github.com/your-org/linter-config.git
Key Fields
path— Where the submodule is checked out in the working treeurl— The remote URL to clone from (HTTPS or SSH)branch— The branch to track when using--remote(defaults to the remote HEAD)shallow— Iftrue, clone with--depth 1update— The default update strategy:checkout(default),merge,rebase, ornone
The .gitmodules file is the public configuration that all collaborators share. There is also a per-user configuration in .git/config that gets populated when you run git submodule init. You can override URLs locally without changing the shared file:
# Override a submodule URL in your local config only
git config submodule.libs/shared-utils.url git@github.com:my-fork/shared-utils.git
Working with Submodule Branches
By default, submodules are checked out in "detached HEAD" state at the exact commit the parent records. This is intentional — it guarantees reproducible builds. But it means you cannot commit directly without first creating or checking out a branch.
Configuring a Tracking Branch
# Set the branch to track in .gitmodules
git config -f .gitmodules submodule.libs/shared-utils.branch main
# Or edit .gitmodules directly to add: branch = main
Now git submodule update --remote will fetch the latest commit from main instead of whatever the remote HEAD is.
Checking Out a Branch Inside a Submodule
# Enter the submodule
cd libs/shared-utils
# You are in detached HEAD state. Check out a branch:
git checkout main
# Or create a feature branch
git checkout -b feature/add-validation
# Make changes, commit, push as normal
git add .
git commit -m "Add input validation helpers"
git push origin feature/add-validation
# Return to the parent repo
cd ../..
# The parent now sees the submodule at your new commit
git add libs/shared-utils
git commit -m "Update shared-utils: add input validation"
Keeping All Submodules on Branches
# Run a command inside every submodule
git submodule foreach 'git checkout main || true'
# Fetch latest in all submodules
git submodule foreach 'git pull origin main'
Committing Changes in Submodules
The workflow for making changes inside a submodule has two parts: committing in the submodule itself, then updating the parent's reference.
Step-by-Step Workflow
# 1. Enter the submodule
cd libs/shared-utils
# 2. Make sure you are on a branch (not detached HEAD)
git checkout main
# 3. Make your changes
vim src/helpers.ts
# 4. Commit inside the submodule
git add src/helpers.ts
git commit -m "Add string sanitization helper"
# 5. Push the submodule changes to its remote
git push origin main
# 6. Go back to the parent repository
cd ../..
# 7. The parent sees the submodule has moved to a new commit
git status
# modified: libs/shared-utils (new commits)
# 8. Stage and commit the new submodule reference
git add libs/shared-utils
git commit -m "Update shared-utils: add string sanitization"
# 9. Push the parent
git push origin main
Critical rule: Always push the submodule before pushing the parent. If you push the parent first, other developers will pull a submodule reference that points to a commit they cannot fetch.
Preventing Broken References
# Push the parent only if all submodules have been pushed
git push --recurse-submodules=check origin main
# Or automatically push submodules first, then the parent
git push --recurse-submodules=on-demand origin main
Set this as the default behavior:
git config --global push.recurseSubmodules on-demand
Removing Submodules Properly
Removing a submodule is the most notoriously fiddly operation. Here is the complete, reliable process:
# 1. Deinit the submodule (removes it from .git/config)
git submodule deinit -f libs/shared-utils
# 2. Remove from the working tree and .gitmodules
git rm -f libs/shared-utils
# 3. Clean up the .git/modules directory
rm -rf .git/modules/libs/shared-utils
# 4. Commit the removal
git commit -m "Remove shared-utils submodule"
All four steps are necessary for a clean removal. If you skip step 3, the cached module data remains and can cause issues if you later add a different submodule at the same path.
Quick Reference
| Step | Command | What It Does |
|---|---|---|
| 1 | git submodule deinit -f <path> | Unregisters the submodule, clears its working tree |
| 2 | git rm -f <path> | Removes from index and updates .gitmodules |
| 3 | rm -rf .git/modules/<path> | Deletes the cached submodule Git data |
| 4 | git commit | Records the removal in history |
Submodules vs Subtrees vs Monorepos
Submodules are not the only way to include external code. Here is how the three main approaches compare:
| Aspect | Submodules | Subtrees | Monorepo |
|---|---|---|---|
| Repo structure | Separate repos linked by reference | External code merged into your tree | All code in one repo |
| Clone complexity | Requires --recurse-submodules | Standard clone | Standard clone (may be large) |
| Version pinning | Exact commit SHA | Manual (squash commit) | Always latest (HEAD) |
| Upstream contributions | Easy (push from submodule) | Possible but awkward | N/A (same repo) |
| Contributor friction | High (extra commands) | Low (files are just there) | Low |
| Access control | Per-repo permissions | Same as parent | Same repo (use CODEOWNERS) |
| CI/CD setup | Needs submodule init step | No extra steps | No extra steps |
Choose Submodules When:
- You need independent versioning and release cycles for the shared code
- Different teams own different repos with different permissions
- You want to pin dependencies to exact commits for reproducible builds
- The shared code is actively developed and you contribute back upstream
Choose Subtrees When:
- You want minimal friction for contributors (no extra commands)
- The external code is rarely updated
- You do not need to push changes back upstream
Choose a Monorepo When:
- One team owns all the code
- You want atomic cross-project commits
- You have tooling (Nx, Turborepo, Bazel) to handle the scale
CI/CD with Submodules
CI/CD pipelines are the most common place submodules break. By default, most CI systems only clone the parent repo without initializing submodules. Here is how to fix it for the major platforms.
GitHub Actions
# .github/workflows/build.yml
name: Build
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive # Initialize all submodules
# For private submodules, use a PAT or SSH key:
# token: ${{ secrets.SUBMODULE_PAT }}
- run: npm install
- run: npm test
If your submodules are in private repositories, you need a Personal Access Token (PAT) with repo scope or configure SSH keys. See our GitHub Actions CI/CD Guide for detailed setup.
GitLab CI
# .gitlab-ci.yml
variables:
GIT_SUBMODULE_STRATEGY: recursive
# For private submodules, use:
# GIT_SUBMODULE_FORCE_HTTPS: "true"
stages:
- build
- test
build:
stage: build
script:
- npm install
- npm run build
test:
stage: test
script:
- npm test
Generic CI (Jenkins, CircleCI, etc.)
# Add this to your build steps after checkout
git submodule update --init --recursive
# For faster builds, use shallow submodule clones
git submodule update --init --recursive --depth 1
SSH Key Configuration for Private Submodules
# In CI, set up SSH before cloning submodules
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan github.com >> ~/.ssh/known_hosts
# Alternative: rewrite HTTPS URLs to use a token
git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "git@github.com:"
Common Pitfalls and Solutions
Pitfall 1: Empty Submodule Directories After Clone
Problem: You cloned a repo and the submodule directories exist but are empty.
Solution: Run git submodule update --init --recursive. Next time, clone with --recurse-submodules.
Pitfall 2: Detached HEAD When Entering a Submodule
Problem: You cd into a submodule and find yourself on "(no branch)".
Solution: This is normal. Submodules default to detached HEAD at the pinned commit. If you need to make changes, check out a branch first: git checkout main.
Pitfall 3: Pushing the Parent Before the Submodule
Problem: You commit a new submodule reference in the parent and push it. Colleagues pull and get an error because the submodule commit does not exist on the remote.
Solution: Always push the submodule first. Or use git push --recurse-submodules=on-demand which pushes submodules automatically.
Pitfall 4: Forgetting to Commit the Submodule Update in the Parent
Problem: You run git submodule update --remote, the submodule advances, but you forget to git add and commit the new reference in the parent. Other developers never see the update.
Solution: After any --remote update, always stage and commit: git add <submodule-path> && git commit.
Pitfall 5: Merge Conflicts on Submodule Pointers
Problem: Two developers updated the same submodule to different commits. Git reports a conflict on the submodule path.
Solution: Decide which commit to use, then:
# Check out the version you want
cd libs/shared-utils
git checkout <desired-commit>
cd ../..
# Stage the resolution
git add libs/shared-utils
git rebase --continue # or git merge --continue
For more on conflict resolution, see our Git Rebase Complete Guide.
Pitfall 6: Submodule URL Changes
Problem: The submodule repo moved to a new URL. Everyone's clones point to the old one.
Solution: Update .gitmodules, sync, and commit:
# Update the URL in .gitmodules
git config -f .gitmodules submodule.libs/shared-utils.url https://github.com/new-org/shared-utils.git
# Sync the URL to .git/config
git submodule sync
# Commit the change
git add .gitmodules
git commit -m "Update shared-utils submodule URL"
Best Practices for Managing Dependencies
1. Pin to Tagged Releases
Instead of tracking a branch tip, pin submodules to version tags for stability:
cd libs/shared-utils
git fetch --tags
git checkout v2.4.1
cd ../..
git add libs/shared-utils
git commit -m "Pin shared-utils to v2.4.1"
2. Use --recurse-submodules Everywhere
Configure Git to handle submodules automatically:
# Auto-update submodules on pull, checkout, and switch
git config --global submodule.recurse true
# This is equivalent to passing --recurse-submodules to
# git pull, git checkout, git switch, and git read-tree
3. Document Submodule Setup in Your README
New contributors will not know about submodules unless you tell them. Add clone instructions to your README:
# Clone with submodules
git clone --recurse-submodules https://github.com/your-org/project.git
# Or if already cloned
git submodule update --init --recursive
4. Set Up Push Safety
# Prevent pushing parent if submodule commits are not pushed
git config --global push.recurseSubmodules on-demand
5. Use a Wrapper Script for Complex Setups
For projects with many submodules, create a setup script:
#!/bin/bash
# setup.sh - Initialize the project
set -e
echo "Initializing submodules..."
git submodule update --init --recursive
echo "Checking out tracked branches..."
git submodule foreach 'git checkout $(git config -f $toplevel/.gitmodules submodule.$sm_path.branch || echo main)'
echo "Project ready."
6. Review Submodule Diffs Before Committing
# See which submodules changed and to which commits
git diff --submodule
# More detailed: show the log of new commits in submodules
git diff --submodule=log
# Show submodule changes in status
git status --short
Alternatives to Submodules
Git Subtree
Git subtree merges an external repo's files directly into your project. No .gitmodules, no detached HEAD, no init step:
# Add a subtree
git subtree add --prefix=libs/shared-utils https://github.com/your-org/shared-utils.git main --squash
# Pull updates from the remote
git subtree pull --prefix=libs/shared-utils https://github.com/your-org/shared-utils.git main --squash
# Push local changes back upstream
git subtree push --prefix=libs/shared-utils https://github.com/your-org/shared-utils.git main
The trade-off: your repo's history grows with every subtree pull, and contributing back upstream is more complex than with submodules.
Language-Specific Package Managers
For published libraries, package managers are usually the better choice:
- npm / yarn / pnpm — JavaScript and TypeScript packages
- Go modules — Go dependencies with semantic versioning
- pip / Poetry — Python packages from PyPI or private indexes
- Maven / Gradle — Java artifacts from Maven Central or Artifactory
- Cargo — Rust crates with built-in dependency resolution
- NuGet — .NET packages
Use submodules when the dependency is unpublished, requires local modifications, or spans multiple languages.
Git Sparse Checkout + Partial Clone
For monorepo-style setups where you want to avoid cloning everything:
# Clone without downloading all blobs
git clone --filter=blob:none --sparse https://github.com/your-org/monorepo.git
# Check out only the directories you need
cd monorepo
git sparse-checkout set services/api libs/shared-utils
This combines the benefits of a monorepo (atomic commits, single clone) with the performance of smaller checkouts. For projects using worktrees alongside this, see our Git Worktrees Complete Guide.
Frequently Asked Questions
What is the difference between git submodules and git subtrees?
Git submodules store a reference (URL and commit SHA) to an external repository, keeping it as a separate Git repo inside your project. Git subtrees merge the external repository's files directly into your project tree, so there is no separate .git directory or .gitmodules file. Submodules require contributors to run extra commands (git submodule init/update) but keep a clean separation. Subtrees are simpler for contributors since the files are just there, but they bloat your repository history and make upstream contributions harder. Use submodules when you need clear boundaries and version pinning; use subtrees when you want zero friction for contributors.
How do I clone a repository that contains submodules?
Use git clone --recurse-submodules <url> to clone the repository and all its submodules in one step. If you already cloned without that flag, run git submodule init followed by git submodule update, or combine them with git submodule update --init --recursive. The --recursive flag ensures nested submodules (submodules inside submodules) are also initialized and checked out.
How do I properly remove a git submodule?
Removing a submodule requires several steps: first run git submodule deinit -f <path> to unregister it, then run git rm -f <path> to remove it from the working tree and .gitmodules, and finally delete the leftover directory in .git/modules/<path> with rm -rf .git/modules/<path>. Commit the changes after these steps. Since Git 2.12, git rm <path> handles most of the cleanup, but the .git/modules directory must still be removed manually to fully clean up.
How do I update a submodule to the latest commit on its remote branch?
Run git submodule update --remote <submodule-path> to fetch the latest commit from the submodule's tracked branch (defaults to the remote HEAD, usually main). This updates the submodule checkout to the latest remote commit. You then need to commit the updated submodule reference in the parent repository with git add <submodule-path> and git commit. To update all submodules at once, omit the path: git submodule update --remote.
Why does my CI/CD pipeline fail with empty submodule directories?
Most CI/CD systems clone repositories without initializing submodules by default. In GitHub Actions, set the submodules option to recursive in the actions/checkout step. In GitLab CI, set GIT_SUBMODULE_STRATEGY: recursive in your variables. For other CI systems, add git submodule update --init --recursive as a build step after checkout. If submodules are in private repos, ensure your CI has SSH keys or tokens with access to those repositories.
Continue Learning
This guide is part of our Git deep-dive series. Explore the related guides to build a complete understanding of Git workflows:
- Git Rebase: The Complete Guide — Interactive rebase, squash commits, and conflict resolution
- Git Branching Strategies Guide — Git Flow, GitHub Flow, and Trunk-Based Development
- Git Worktrees Complete Guide — Work on multiple branches simultaneously
- GitHub Actions CI/CD Guide — Automate builds, tests, and deployments