Git Submodules: The Complete Guide for 2026
Git submodules let you embed one Git repository inside another. They are the standard mechanism for including shared libraries, vendored dependencies, or any project that maintains its own history and release cycle. Submodules are powerful but notoriously confusing — the commands are verbose, the mental model is different from regular Git, and mistakes can leave your repository in a puzzling state.
This guide covers everything you need to use submodules confidently: adding and removing them, cloning repos that contain them, updating strategies, branch tracking, nested submodules, CI/CD pipelines, and a clear comparison with alternatives like subtree and monorepos.
Table of Contents
- What Are Git Submodules?
- Adding Submodules
- Cloning Repos with Submodules
- Updating Submodules
- Tracking Branches vs Specific Commits
- Removing Submodules
- .gitmodules File Explained
- Nested Submodules
- CI/CD with Submodules
- Submodules vs Subtree vs Monorepo
- Common Pitfalls and Troubleshooting
- Real-World Use Cases
- FAQ
What Are Git Submodules?
A Git submodule is a pointer from one repository (the parent) to a specific commit in another repository (the child). The parent does not store the child's files directly. Instead, it records two things: the URL of the child repository and the exact commit hash to check out.
Parent repository
├── .gitmodules # Declares submodule URLs and paths
├── .git/modules/ # Stores submodule Git databases
├── src/
│ └── app.js
└── libs/
└── shared-utils/ # Submodule: a full Git repo at a pinned commit
├── .git # File pointing to ../../.git/modules/libs/shared-utils
├── index.js
└── package.json
When you look at the parent repo's commit history, the submodule appears as a special entry (a "gitlink") that records the commit hash. Git does not track the submodule's individual files — it only tracks which commit the submodule should be at.
When to Use Submodules
- Shared libraries — A utility library used by multiple projects, each pinning the version they need
- Vendored dependencies — Third-party code you want to track at a known commit instead of using a package manager
- Multi-repo architecture — Microservices or components that have independent release cycles but need to be tested together
- Documentation or config repos — Shared configuration, schemas, or docs embedded into service repos
When NOT to Use Submodules
- Tightly coupled code — If two projects change together in every PR, they belong in the same repo
- Simple dependencies — Use your language's package manager (npm, pip, Maven) instead
- Large binary assets — Use Git LFS instead of submodules for media files
- Teams unfamiliar with Git — Submodules add complexity; consider subtree or a monorepo for simpler workflows
Adding Submodules
The git submodule add command registers a new submodule in your repository:
# Add a submodule at the default path (repo name)
git submodule add https://github.com/org/shared-utils.git
# Add a submodule at a specific path
git submodule add https://github.com/org/shared-utils.git libs/shared-utils
# Add a submodule and track a specific branch
git submodule add -b main https://github.com/org/shared-utils.git libs/shared-utils
This command does three things:
- Clones the external repository into the specified path
- Creates or updates the
.gitmodulesfile with the submodule's URL and path - Stages both the
.gitmodulesfile and the new submodule directory (as a gitlink)
You still need to commit:
git commit -m "Add shared-utils submodule at libs/shared-utils"
After committing, 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
When you clone a repository that contains submodules, the submodule directories exist but are empty by default. You have two options:
Option 1: Clone with --recurse-submodules
# Clone and initialize all submodules in one step
git clone --recurse-submodules https://github.com/org/my-project.git
# Same thing, older syntax (still works)
git clone --recursive https://github.com/org/my-project.git
This is the simplest approach and should be your default when cloning any project that uses submodules.
Option 2: Initialize After Cloning
If you already cloned without the flag:
# Initialize and fetch all submodules
git submodule update --init
# Initialize and fetch all submodules, including nested ones
git submodule update --init --recursive
The --init flag registers submodules listed in .gitmodules into your local .git/config. The --recursive flag handles submodules that themselves contain submodules. Always use --recursive unless you know there are no nested submodules.
Selective Initialization
In large projects with many submodules, you might not need all of them:
# Initialize only specific submodules
git submodule init libs/shared-utils libs/auth-module
git submodule update
# Or use a single command for specific paths
git submodule update --init libs/shared-utils
Updating Submodules
Updating submodules is the operation that causes the most confusion. There are two distinct scenarios: checking out the commit the parent expects, and pulling new changes from the submodule's remote.
Scenario 1: Sync to the Parent's Recorded Commit
After a git pull on the parent repo, submodules may point to different commits than what you have checked out locally. Run:
# Update all submodules to the commits recorded by the parent
git submodule update --recursive
# If submodules were recently added and not yet initialized
git submodule update --init --recursive
This checks out the exact commit the parent repo says each submodule should be at. It does not fetch new commits from the submodule's remote — it only adjusts the local checkout.
Scenario 2: Pull New Changes from Upstream
To update a submodule to the latest commit on its tracked branch:
# Fetch and merge the latest changes for all submodules
git submodule update --remote
# Fetch and merge for a specific submodule
git submodule update --remote libs/shared-utils
# Fetch and rebase instead of merge
git submodule update --remote --rebase
After updating with --remote, the parent repo will show the submodule as modified (because the recorded commit changed). You need to commit this change:
git add libs/shared-utils
git commit -m "Update shared-utils to latest upstream"
Updating on Every Pull
Configure Git to automatically update submodules when you pull:
# Auto-update submodules on pull, checkout, and merge
git config --global submodule.recurse true
With this setting, git pull behaves like git pull && git submodule update --init --recursive.
Tracking Branches vs Specific Commits
By default, submodules are pinned to a specific commit hash. This is the safest approach because your build is fully reproducible — no matter when someone clones, they get the exact same code. But sometimes you want a submodule to follow a branch.
Pinned Commits (Default)
# The parent records a specific commit hash
$ git submodule status
a1b2c3d4e5f6... libs/shared-utils (v2.3.1)
# To update the pin, cd into the submodule and check out a new commit
cd libs/shared-utils
git fetch origin
git checkout v2.4.0
cd ../..
# Then commit the new pin in the parent
git add libs/shared-utils
git commit -m "Bump shared-utils to v2.4.0"
Branch Tracking
To have a submodule follow a branch, set it in .gitmodules:
# Set the branch to track
git config -f .gitmodules submodule.libs/shared-utils.branch main
# Now --remote will fetch the latest commit on that branch
git submodule update --remote libs/shared-utils
Even with branch tracking, the parent still records a specific commit hash. The difference is that --remote knows which branch to look at when fetching updates. You still need to commit the updated hash in the parent.
Which Approach to Choose
| Aspect | Pinned Commit | Branch Tracking |
|---|---|---|
| Reproducibility | Exact — always the same code | Depends on when you last ran --remote |
| Update effort | Manual checkout and commit | One command: update --remote |
| Best for | Stable dependencies, production | Active development, integration testing |
| Risk | Falling behind upstream | Breaking changes from upstream |
Removing Submodules
Removing a submodule is the most frequently searched submodule operation because it requires multiple steps. Simply deleting the directory is not enough — Git stores submodule metadata in three locations.
Complete Removal Process
# Step 1: Deinit the submodule (removes it from .git/config)
git submodule deinit -f libs/shared-utils
# Step 2: Remove the submodule entry and directory (updates .gitmodules and staging)
git rm -f libs/shared-utils
# Step 3: Clean up the Git metadata directory
rm -rf .git/modules/libs/shared-utils
# Step 4: Commit the removal
git commit -m "Remove shared-utils submodule"
What Each Step Does
git submodule deinit -f— Unregisters the submodule from.git/configand removes the submodule's working directory contentsgit rm -f— Removes the submodule entry from the index, deletes the directory, and removes the corresponding section from.gitmodulesrm -rf .git/modules/...— Removes the cached Git database for the submodule. Without this step, re-adding the same submodule later can fail because Git finds stale cached data
If you skip the rm -rf .git/modules/ step and later try to re-add the submodule, Git may use the stale cached data and produce confusing errors. Always complete all three steps.
.gitmodules File Explained
The .gitmodules file is a configuration file tracked by Git that declares all submodules in the project. Here is an example:
[submodule "libs/shared-utils"]
path = libs/shared-utils
url = https://github.com/org/shared-utils.git
branch = main
[submodule "libs/auth-module"]
path = libs/auth-module
url = https://github.com/org/auth-module.git
[submodule "vendor/legacy-parser"]
path = vendor/legacy-parser
url = https://github.com/thirdparty/legacy-parser.git
shallow = true
Key Fields
path— Where the submodule is checked out in the working treeurl— The remote URL to clone from. Can be absolute or relative (e.g.,../other-repo.git)branch— The branch to track when usinggit submodule update --remote. Defaults to the remote's HEAD if omittedshallow— When set totrue, clones only the needed commit (shallow clone), saving disk space and timeignore— Controls whengit statusreports the submodule as dirty:all,dirty,untracked, ornone
Overriding URLs Locally
Each developer can override a submodule's URL without changing the committed .gitmodules:
# Use SSH instead of HTTPS for a submodule
git config submodule.libs/shared-utils.url git@github.com:org/shared-utils.git
# Sync URLs from .gitmodules to .git/config
git submodule sync
The sync command is useful after .gitmodules changes (e.g., a repository was moved to a new URL). It updates your local .git/config to match.
Nested Submodules
Submodules can contain their own submodules. This is common in large ecosystems where a shared library depends on other shared libraries. Git handles this through recursive operations.
parent-project/
├── libs/
│ └── framework/ # Submodule
│ ├── src/
│ └── deps/
│ └── core-lib/ # Nested submodule (submodule within a submodule)
│ └── src/
Working with Nested Submodules
# Initialize and update all levels of submodules
git submodule update --init --recursive
# Clone with all nested submodules
git clone --recurse-submodules https://github.com/org/parent-project.git
# Fetch upstream changes for all nested submodules
git submodule update --remote --recursive
# Run a command in every submodule (including nested ones)
git submodule foreach --recursive 'git fetch origin'
The --recursive flag is the key. Without it, Git only processes the top-level submodules. With it, Git descends into each submodule and processes its submodules too, all the way down.
The foreach Command
The foreach command runs an arbitrary shell command in every submodule:
# Check the status of every submodule
git submodule foreach 'git status'
# Pull latest changes in every submodule
git submodule foreach 'git pull origin main'
# Show which branch each submodule is on
git submodule foreach 'echo "$name: $(git branch --show-current)"'
# Clean all submodules
git submodule foreach --recursive 'git clean -fd'
CI/CD with Submodules
Submodules require extra configuration in CI/CD pipelines because most runners do a shallow clone without initializing submodules. Here is how to handle them in popular CI systems.
GitHub Actions
steps:
- name: Checkout with submodules
uses: actions/checkout@v4
with:
submodules: recursive # 'true' for top-level only, 'recursive' for nested
token: ${{ secrets.PAT_TOKEN }} # needed if submodules are private repos
If your submodules are in private repositories, the default GITHUB_TOKEN may not have access. Use a Personal Access Token (PAT) with repo scope.
GitLab CI
variables:
GIT_SUBMODULE_STRATEGY: recursive # 'normal' for top-level only
GIT_SUBMODULE_DEPTH: 1 # shallow clone submodules for speed
build:
script:
- echo "Submodules are already initialized"
Jenkins
pipeline {
agent any
stages {
stage('Checkout') {
steps {
checkout([
$class: 'GitSCM',
extensions: [[$class: 'SubmoduleOption',
recursiveSubmodules: true,
trackingSubmodules: false
]],
userRemoteConfigs: [[url: 'https://github.com/org/project.git']]
])
}
}
}
}
Generic CI (Bash)
For any CI system that gives you a basic checkout, add this step:
# After the initial clone/checkout
git submodule sync --recursive
git submodule update --init --recursive --depth 1
The --depth 1 flag performs a shallow clone of each submodule, significantly speeding up CI pipelines by skipping full history downloads.
For more CI/CD patterns, see our GitHub Actions CI/CD Complete Guide.
Submodules vs Subtree vs Monorepo
Submodules are not the only way to include external code. Here is how they compare with the two main alternatives.
Git Subtree
git subtree merges an external repository's files directly into your project tree. There is no special metadata, no .gitmodules file, and no extra init steps for consumers.
# Add a subtree
git subtree add --prefix=libs/shared-utils https://github.com/org/shared-utils.git main --squash
# Pull updates
git subtree pull --prefix=libs/shared-utils https://github.com/org/shared-utils.git main --squash
# Push changes back to the original repo
git subtree push --prefix=libs/shared-utils https://github.com/org/shared-utils.git main
Monorepo
A monorepo puts all projects in a single repository. Tools like Nx, Turborepo, Bazel, and Pants handle builds and dependency management within the monorepo.
Comparison Table
| Aspect | Submodule | Subtree | Monorepo |
|---|---|---|---|
| External repo stays separate | Yes | Files are merged in | No external repo |
| Consumer complexity | Must run init/update | Just clone, files are there | Just clone |
| Push changes upstream | cd into submodule, commit, push | git subtree push | N/A (same repo) |
| History | Separate per repo | Mixed into parent | Single unified history |
| Version pinning | Exact commit hash | Squash commit in parent | Everything at HEAD |
| CI/CD setup | Needs init/update step | No extra steps | Needs build tool (Nx, Bazel) |
| Best for | Independent projects, stable deps | Simple inclusion, rare upstream pushes | Tightly coupled projects, large orgs |
Common Pitfalls and Troubleshooting
Pitfall 1: Detached HEAD in Submodules
Problem: Every submodule is checked out with a detached HEAD, which is confusing if you try to make commits inside it.
Solution: This is by design. Before making changes in a submodule, check out a branch first:
cd libs/shared-utils
git checkout main
# ... make changes, commit, push ...
cd ../..
git add libs/shared-utils
git commit -m "Update shared-utils submodule"
Pitfall 2: Forgetting to Push Submodule Changes
Problem: You commit a submodule update in the parent that points to a commit that only exists on your local machine. When teammates pull, the submodule update fails because the commit does not exist on the remote.
Solution: Always push the submodule first, then push the parent:
# Push the submodule
cd libs/shared-utils
git push origin main
cd ../..
# Then push the parent
git push origin main
# Or use --recurse-submodules to push both
git push --recurse-submodules=on-demand
The --recurse-submodules=on-demand flag automatically pushes any submodules that have local commits before pushing the parent. Make it the default:
git config --global push.recurseSubmodules on-demand
Pitfall 3: Submodule Shows as Modified After Pull
Problem: After git pull, git status shows the submodule as modified even though you did not change anything.
Solution: The parent pulled a new commit hash for the submodule, but your local submodule checkout has not been updated yet. Run:
git submodule update --recursive
Pitfall 4: Permission Errors with Private Submodules
Problem: git submodule update fails with authentication errors because the submodule URL requires credentials you have not configured.
Solution: Use SSH URLs in .gitmodules or configure a credential helper. For CI, use deploy keys or access tokens:
# Convert HTTPS URLs to SSH for all submodules
git config --global url."git@github.com:".insteadOf "https://github.com/"
Pitfall 5: Re-adding a Previously Removed Submodule Fails
Problem: After removing a submodule, trying to add it again produces errors about cached data.
Solution: Ensure you removed .git/modules/<path> during the removal process. If you forgot:
rm -rf .git/modules/libs/shared-utils
git submodule add https://github.com/org/shared-utils.git libs/shared-utils
Pitfall 6: Submodule URL Changed Upstream
Problem: The submodule's remote repository moved to a new URL. Fetches fail with "repository not found."
Solution: Update .gitmodules and sync:
# Edit .gitmodules with the new URL
git config -f .gitmodules submodule.libs/shared-utils.url https://github.com/new-org/shared-utils.git
# Sync the change to .git/config
git submodule sync
# Fetch from the new URL
git submodule update --remote libs/shared-utils
Real-World Use Cases
Use Case 1: Shared Component Library
A design system repository is included as a submodule in multiple frontend applications. Each app pins the version of the design system it is compatible with. When the design system releases a new version, each app team updates the submodule pin on their own schedule after testing.
# App repo structure
my-web-app/
├── src/
├── libs/
│ └── design-system/ # Submodule pinned to v3.2.0
└── package.json
Use Case 2: Proto/Schema Repository
Microservices that communicate via Protocol Buffers or GraphQL share a schema repository. Each service includes it as a submodule and generates code from the schemas during build.
# Each service includes the shared schemas
order-service/
├── proto/
│ └── shared-schemas/ # Submodule
├── generated/ # Built from submodule proto files
└── src/
Use Case 3: Documentation Site
A documentation website pulls in README files from multiple project repositories. Each project repo is a submodule, and the docs build system extracts markdown from each one.
Use Case 4: Vendored Fork
You depend on an open-source library that you have forked to apply custom patches. The fork is included as a submodule pinned to your patched commit. When the upstream releases a new version, you rebase your patches and update the pin.
# Vendor a forked dependency
git submodule add https://github.com/your-org/forked-lib.git vendor/forked-lib
# When upstream updates, inside the submodule:
cd vendor/forked-lib
git fetch upstream
git rebase upstream/main
git push origin main
cd ../..
git add vendor/forked-lib
git commit -m "Rebase forked-lib onto upstream v4.1.0"
Frequently Asked Questions
What is a Git submodule and when should I use one?
A Git submodule is a reference to another Git repository embedded inside a parent repository at a specific commit. The parent repo tracks which commit the submodule should point to, but the submodule's full history lives in its own repository. Use submodules when you need to include a shared library, a vendored dependency, or any project that has its own release cycle and contributors. They work best when the included project is relatively stable and updated infrequently.
How do I clone a repository that contains submodules?
Use git clone --recurse-submodules <url> to clone the parent repository and all its submodules in one step. If you already cloned without that flag, run git submodule update --init --recursive inside the repository to fetch and check out all submodules, including nested ones. The --recursive flag is important because submodules can themselves contain submodules.
How do I completely 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 the submodule directory and its entry from .gitmodules, and finally delete the leftover metadata with rm -rf .git/modules/<path>. Commit the changes afterward. Simply deleting the directory is not enough because Git stores submodule metadata in three places: .gitmodules, .git/config, and .git/modules/.
What is the difference between git submodule and git subtree?
Git submodules keep a reference (pointer) to an external repository at a specific commit, while git subtree merges the external repository's files directly into your project's tree. Submodules require everyone to run extra commands (init, update) and the external repo stays separate. Subtree is simpler for consumers since the files are just regular files in your repo, but pushing changes back upstream is more complex. Use submodules when the dependency is actively developed elsewhere; use subtree when you want a simpler workflow and rarely push changes back.
Why is my submodule showing a dirty diff or detached HEAD?
Submodules are always checked out in a detached HEAD state because the parent repo tracks a specific commit hash, not a branch. This is normal behavior. A dirty diff (modified content) appears when the submodule's working directory has uncommitted changes or when the checked-out commit differs from what the parent expects. Run git submodule update to reset the submodule to the expected commit, or cd into the submodule and commit your changes if you intend to update it.
Continue Learning
This guide is part of our Git deep-dive series. Explore the related guides to master every aspect of Git:
- The Complete Guide to Git — Fundamentals through advanced workflows
- Git Branching Strategies Guide — Git Flow, GitHub Flow, and Trunk-Based Development
- Git Rebase: The Complete Guide — Interactive rebase, squash, autosquash, and recovery
- GitHub Actions CI/CD Complete Guide — Automate builds, tests, and deployments