Skip to content
ExponaBLOG
GIT & GITHUB FOR PROFESSIONAL DEVELOPERS · DAY 053 / 100

Git submodules: use with care — best practices explained

Nested repositories have sharp edges.

Expona Admin7 min read

Git submodules: the nested repository trap

We've all been there: your monorepo needs to share code with another repository, and someone suggests Git submodules. It sounds elegant—version control all the way down. But nested repositories have sharp edges, and understanding when and how to use them separates teams that move fast from teams that spend hours debugging checkout issues. Today we're covering Git submodules: use with care — best practices explained, focusing on the real-world scenarios where they make sense and the many where they don't.

Understanding what a submodule actually does

A Git submodule is a reference to a specific commit in another repository. When you add a submodule to your project, Git stores a pointer (a commit SHA) and the remote URL in your `.gitmodules` file and a hidden entry in your `.git/config`. The actual code from the submodule doesn't live in your repository—it's fetched separately when you clone or update.

This design creates a fundamental disconnect that trips up most teams. Your main repository and the submodule repository have independent histories. They're version-controlled separately, but you're trying to treat them as a single unit. That tension is where problems emerge.

Pin a specific commit of another repo

The core value proposition of submodules is version pinning. When you add a submodule, you're saying "I want the code from `repo-b` at exactly commit `abc123def`—not the latest, not main branch, that specific commit." This is useful when you have a shared library that multiple teams depend on and you want explicit control over when you upgrade.

Let's say we have a shared authentication library in a separate repository, and we're adding it as a submodule to our main application:

bash
git submodule add https://github.com/company/auth-library.git ./vendor/auth-lib
git commit -m "Add auth-library submodule"

This creates a `.gitmodules` file that looks like:

bash
[submodule "vendor/auth-lib"]
	path = vendor/auth-lib
	url = https://github.com/company/auth-library.git

And your `.git/modules` directory now tracks that you're pinned to a specific commit. When you want to upgrade the submodule to a newer version of the auth library, you do this explicitly:

bash
cd vendor/auth-lib
git fetch origin
git checkout v2.1.0
cd ../..
git add vendor/auth-lib
git commit -m "Upgrade auth-library to v2.1.0"

Notice the ceremony: you cd into the submodule, fetch, checkout, then return and stage the submodule pointer update. This friction is intentional—it forces you to think about upgrades rather than them happening automatically. In a world of dependency chaos, that's sometimes valuable. But it's also where submodules become a tax on every developer's workflow.

Clone with --recurse-submodules

Here's where the first real gotcha appears. When you clone a repository that contains submodules, by default Git doesn't fetch the submodule code. You get the `.gitmodules` file and the pointers, but the directories are empty.

A developer on your team clones the repo, tries to build, and gets "auth-lib not found" errors. They run `git status` and see nothing wrong. They Google, find a Stack Overflow answer from 2014, and eventually run:

bash
git submodule update --init --recursive

This works, but now every developer on the team needs to know this incantation. Better: train everyone to clone with the flag that does it all at once:

bash
git clone --recurse-submodules https://github.com/company/main-app.git

Or update your team's getting-started documentation to include this step explicitly. Even better, add it to a setup script that your repo provides:

bash
#!/bin/bash
git clone https://github.com/company/main-app.git
cd main-app
git submodule update --init --recursive
npm install
# other setup...

The pattern here is: submodules require explicit, repeated manual steps from every developer. That scales poorly. Each time a new person joins the team, they'll hit this cliff. Each time a CI/CD pipeline is set up, someone needs to remember the flag. It's not a deal-breaker for small teams with stable membership, but it's a recurring friction point.

Easy to forget updates

Submodules are stateful in a way most developers aren't used to. Your repository contains a pointer to a submodule commit, but developers working on the submodule in another context might be on main, or a different branch, or several commits ahead. This creates scenarios where your submodule reference becomes stale without you realizing it.

Imagine your shared library gets an important security fix committed to main. Your main application is running on an older commit of the library—everything seems fine because there are no errors. But you're not actually running the fixed code. This can sit in production for months.

One team member might update the submodule locally to test a fix, but forget to push the updated submodule reference:

bash
cd vendor/auth-lib
git checkout fix/security-issue
cd ../..
# build and test locally—works great
# but oops, forgot to commit the submodule update

Later, CI runs, someone else pulls the code, and they're on the old version. The update never made it to git.

Even when you do update correctly, it's easy to miss in code review. A pull request comes in with one changed line in `.gitmodules` pointing to a new commit. Reviewers often skip over this because they're looking at the actual code changes, not metadata.

Best practice: have a CI check that validates submodule freshness and fails builds if a submodule is out of sync with a known-good version. But this adds complexity to your build pipeline just to manage the complexity of submodules.

Package managers are often better

Before you reach for submodules, ask yourself: does this problem already have a simpler solution?

For most scenarios, language-specific package managers solve the versioning problem more elegantly. If your shared auth library is a Node.js package, publish it to npm. If it's a .NET library, publish to NuGet. Your `package.json` or `.csproj` becomes the source of truth for versions, and the package manager handles installation consistently across developers and CI environments.

The trade-offs:

  • Submodules: direct git control, can pin any commit, but manual, easy to forget, adds complexity
  • Package managers: designed for dependency management, automatic installation, version resolution, but requires publishing workflow (even internal packages)

For most teams, the package manager approach wins. If your library changes frequently and you want developers to always be on the latest code without explicit releases, that's a sign you might not have the right boundary between repositories. Maybe the code should live in a monorepo with shared directories and a build system like Lerna or Nx.

Monorepos with proper tooling have become the default approach for large teams managing multiple related packages. Git submodules were a pre-monorepo solution that still has niche use cases, but they're worth avoiding if you have alternatives.

When submodules actually make sense

That said, there are scenarios where submodules are the right tool:

  • Third-party library in a separate org: You depend on a library maintained by a different team or external vendor, and you want to pin to specific tagged releases. But honestly, a package manager is still cleaner here.
  • Documentation or assets repo: Some teams use submodules to include documentation or build artifacts from a separate repository. The submodule rarely changes, and the cognitive overhead is low.
  • Embedded systems or firmware: In hardware projects, teams sometimes use submodules for bootloader code or vendor SDKs that are maintained separately. The infrequent updates and small team sizes make the workflow manageable.

Notice the pattern: submodules work when updates are rare, the number of developers is small, and there's clear ownership separation. They break down in high-velocity teams with frequent dependency updates.

Common mistakes

Mistake 1: Adding submodules without documenting the setup. Your new hire clones the repo, runs `npm install`, and nothing works. Document the submodule update step or automate it in your setup script. Better yet, reconsider whether you need submodules at all.

Mistake 2: Forgetting to commit submodule updates. You update the submodule locally, the code works, but you only committed your changes—not the submodule pointer. Your CI runs on the old version. Use a linter or CI check that fails if `.gitmodules` is changed but not committed together with its corresponding submodule reference.

Mistake 3: Using submodules for frequently-changing shared code. If a library changes multiple times a week across three repositories, you're fighting submodules. Either consolidate into a monorepo or publish the library to your package manager of choice and update versions intentionally.

Mistake 4: Not pinning to tags. Submodule pointers should reference stable tags or commits, not branch names. Pointing a submodule to main and expecting consistency is a recipe for non-reproducible builds. Always use `git checkout v1.2.3` or a specific commit SHA, never `git checkout main`.

Key takeaways

  • Submodules pin your repository to a specific commit in another repository—useful for version control, but they add manual steps to clone and update operations.
  • Always clone with --recurse-submodules and document this in your setup guide, or newer team members will hit friction immediately.
  • Submodule updates are easy to forget because they require explicit commits and are easy to miss in code review; consider adding CI checks to catch stale submodules.
  • Package managers (npm, NuGet, Maven) are usually better suited for dependency management than submodules; they're designed specifically for this problem and scale better across teams.
  • Submodules make sense in limited contexts: third-party libraries maintained by external teams, documentation or assets that rarely change, and embedded systems work. For most application code, they're a complexity tax.
  • If you find yourself using submodules for frequently-changing shared code across multiple repositories, reconsider your architecture. A monorepo or a proper package manager is likely a better fit.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles