On this page
To find circular dependencies in a monorepo, run one cycle checker per language in CI: madge or dependency-cruiser for TypeScript, import-linter or pylint for Python. Go rejects package cycles at compile time, so check the cycles it allows: between folders and between modules, using go list. Then break each cycle by cutting the fewest imports.
| Language | Tool (version tested) | Command | Fails CI on a cycle by default? | The default that bites |
|---|---|---|---|---|
| TypeScript | madge 8.0.0 | npx madge --circular --extensions ts --ts-config tsconfig.json packages | Yes, exit code 1 | Counts import type cycles unless you set skipTypeImports |
| TypeScript | dependency-cruiser 18.5.0 | npx depcruise packages --config .dependency-cruiser.js | No, no-circular is a warning | Without TypeScript installed locally it checks 0 modules and passes |
| Python | import-linter 2.15 | lint-imports with an acyclic_siblings contract | Yes, exit code 1 | Only checks inside a package; use layers across top-level packages |
| Python | pylint 4.1.2 | pylint <package dirs> --disable=all --enable=cyclic-import | Yes, non-zero exit | Pass directories; my run with bare module names found nothing |
| Python | pydeps 3.0.9 | pydeps <pkg> --show-cycles --no-output --show-deps | No, it is a report | Good for looking, not for gating |
| Go | the Go toolchain | go build ./... and go vet ./... | Yes, for package cycles | Folder-level and module-level cycles are legal and need go list |
Scroll the table sideways to see every column.
Every command below was run on 6 October 2026 against small fixture repos I wrote for this post, so the outputs are real. The fixtures are tiny on purpose, because the goal is to see what each tool does by default; they tell you nothing about speed.
We build repowise, so weigh our entry accordingly. The measurements we publish about it, with their methods, are on the benchmarks page.
Why monorepos collect cycles
In separate repos, a cycle between two packages is hard to create, because each one has to be published before the other can depend on it. A monorepo removes that friction. Workspace links make every package importable from every other one, a pull request can touch both sides at once, and nothing stops orders importing billing in the same week someone makes billing import orders.
A cycle means neither side can be understood, tested, built or released without the other. In TypeScript and Python it also produces the classic bug where a module sees a half-initialised import at startup. So the goal is a check that fails the build when a new cycle appears, plus a one-time cleanup of the ones you have. (If you are still setting the monorepo up, how to index a monorepo covers the graph, docs and ownership side.)
TypeScript: madge for a quick answer, dependency-cruiser for rules
My fixture has four files. a imports b, b imports c, and c imports a, which is a real cycle, and c and d form a second loop where one direction is only import type.
madge gives the fastest answer and exits with 1 when it finds a cycle, so it gates CI without configuration:
npx madge --circular --extensions ts --ts-config tsconfig.json src
# ✖ Found 2 circular dependencies!
# 1) a.ts > b.ts > c.ts
# 2) c.ts > d.ts
The second one is a type-only cycle. It disappears at compile time, so it cannot cause a runtime import problem. To stop madge reporting it, add a .madgerc:
{ "detectiveOptions": { "ts": { "skipTypeImports": true } } }
With that file in place, madge reports only a.ts > b.ts > c.ts.
For a monorepo, point madge at the packages folder that holds every package. In a test with two npm workspace packages importing each other by package name (@m/a and @m/b), madge followed the workspace links and reported a/src/index.ts > b/src/index.ts.
dependency-cruiser is the better tool once you want rules as well as a list of cycles, such as "no cycles" and "nothing in packages/ui imports packages/server". Generate a config, then validate:
npm i -D typescript dependency-cruiser
npx depcruise --init yes
npx depcruise packages --config .dependency-cruiser.js
dependency-cruiser's defaults caught me out twice. The first time, I ran it before installing TypeScript locally, and it printed ✔ no dependency violations found (0 modules, 0 dependencies cruised) with exit code 0. It did print a warning that it had found no compatible TypeScript compiler, but in a CI log that line is easy to miss, and the result looks exactly like a clean repo. Install typescript as a dev dependency in the same package where you run it.
The second problem is that the generated no-circular rule has severity: 'warn', so cycles are reported and the exit code stays 0. Change it to error, and if you want to ignore type-only cycles, add viaOnly:
{
name: 'no-circular',
severity: 'error',
from: {},
to: {
circular: true,
viaOnly: { dependencyTypesNot: ['type-only'] }
}
}
With that, the fixture reports a.ts → b.ts → c.ts → a.ts as an error and exits with 1. It found the cross-package cycle in the workspace test too.
If you use Nx, its @nx/enforce-module-boundaries lint rule already flags circular dependencies between Nx projects. It works at the project level, so keep madge or dependency-cruiser for cycles between files inside a project. I also tried rev-dep, a newer and very fast checker; with --follow-monorepo-packages it did not report the cross-package cycle that madge found in my two-package test. I may have missed a setting, so test it on your own layout before trusting a clean result.
Python: import-linter for the build, pylint and pydeps for looking
The Python fixture is a package shop with three subpackages: orders imports billing, billing imports users, and users imports orders inside a function. That last import is deferred, so it does not break at import time, but it is still a cycle in the design.
import-linter checks contracts you write in pyproject.toml or .importlinter. The acyclic_siblings contract type (added in 2.6, November 2025) forbids cycles between the children of a package and then repeats one level down, up to 10 levels by default:
[tool.importlinter]
root_package = "shop"
[[tool.importlinter.contracts]]
name = "No cycles between shop's subpackages"
type = "acyclic_siblings"
ancestors = ["shop"]
$ lint-imports
No cycles between shop's subpackages BROKEN
No cycles are allowed in shop.
It could be made acyclic by removing 1 dependency:
- .orders -> .billing (1 import)
It counts the deferred import, and it tells you which single dependency to remove, which is the most useful output of any tool here.
In a Python monorepo with separate top-level packages (say packages/billing/src/billing and packages/orders/src/orders), acyclic_siblings on each package checks only that package's insides. It reported my cross-package cycle as kept. What does catch it is a layers contract that states the allowed direction:
[importlinter]
root_packages =
billing
orders
[importlinter:contract:direction]
name = billing may use orders, never the reverse
type = layers
layers =
billing
orders
That fails with orders.api -> billing.core (l.1). It also forces a useful conversation, because someone has to decide which way the arrow should point.
pylint's cyclic-import check (R0401) is the zero-config option. Disable everything else so the run is fast:
pylint packages/billing/src/billing packages/orders/src/orders --disable=all --enable=cyclic-import
# R0401: Cyclic import (billing.core -> orders.api) (cyclic-import)
Pass directories. When I passed the module names billing orders instead, with the same PYTHONPATH, it reported nothing.
pydeps draws the graph, and --show-cycles reduces it to the modules that take part in a cycle. With --no-output --show-deps it prints them as JSON and does not need Graphviz. It exits 0 either way, so use it for inspection and keep it out of CI gating.
Go: the compiler covers package cycles only
Go does not allow import cycles between packages. Two packages that import each other fail to build:
package example.com/shop/billing
imports example.com/shop/orders from billing.go
imports example.com/shop/billing from orders.go: import cycle not allowed
You never need a tool for package cycles, but cycles can still appear in tests, between folders and between modules.
In tests, a test file in package money that imports orders, which imports money, fails with import cycle not allowed in test. The fix is to make it an external test package: change the first line of the test file to package money_test and import money like any other caller. That broke the cycle in my fixture and the tests passed.
Between folders, orders/api can import billing/store while billing/api imports orders/store. No package imports itself, so it builds, but at the folder level orders and billing depend on each other, which is the cycle that hurts when you try to split them into services. go list plus tsort finds it:
M=$(go list -m)
go list -f '{{$p := .ImportPath}}{{range .Imports}}{{$p}} {{.}}{{"\n"}}{{end}}' ./... \
| grep " $M/" | sed "s#$M/##g" \
| awk '{split($1,a,"/"); split($2,b,"/"); if (a[1]!=b[1]) print a[1], b[1]}' \
| sort -u > edges.txt
tsort edges.txt # prints "tsort: cycle in data" and the folders involved
On macOS, tsort reported the cycle but still exited 0, so in CI check its error output for "cycle" instead of the exit code.
Between modules, if your monorepo has several go.mod files, module requirements may form a cycle (module b requires c, and c requires b). That is legal, and the Go toolchain has a regression test for it, but it means you cannot version or release those modules independently.
Once you have found one
The fix is almost always one of three moves: move the shared piece down into a package both sides can import, turn one side's import into an interface the other side implements (dependency inversion), or merge the two if they were never really separate. Before choosing, find the smallest set of imports that would break the cycle. import-linter prints that for Python. For a big cycle in TypeScript or across languages, it is worth computing.
This is where one graph for the whole repo helps. repowise parses 16 languages to a full AST (11 at the Full tier) into a single dependency graph, finds every group of files that import each other in a loop (strongly connected components, in graph terms), and its refactoring plans name a small set of import edges to cut (a greedy approximation of the fewest) to break each one. Its Go analysis also skips the false cycle that appears when files in one Go package refer to each other without import statements. Keep a CI gate like madge or import-linter, because it is cheap; the single graph is for seeing the cycles you already have, with owners and change history next to each file, so you can decide which to cut first.
How I tested
- Fixtures written for this post: a 4-file TypeScript project with one runtime cycle and one type-only cycle; a 2-package npm workspace with a cross-package cycle; a Python package with a 3-module cycle including a deferred import; a 2-package Python monorepo; Go modules with a package cycle, a test cycle and a folder cycle.
- Versions: madge 8.0.0, dependency-cruiser 18.5.0 with TypeScript 6.0.3, import-linter 2.15, pylint 4.1.2, pydeps 3.0.9, rev-dep 3.2.0, Go 1.26.6, on macOS, 6 October 2026.
- What this cannot tell you: speed on a large repo, behaviour with path aliases, Yarn PnP or pnpm's stricter linking, and how each tool handles dynamic imports. Fixtures show default behaviour only, so test each tool on your own repo before relying on it.
FAQ
How do I trace dependencies across a monorepo?
Run the checker from the monorepo root over the folder that holds all packages, not package by package. madge and dependency-cruiser follow npm workspace links into the real files, so cross-package imports show up. In Python, give import-linter all top-level packages in `root_packages`. For a single picture across several languages, use a tool that builds one graph for the whole repo.
What is a good dependency-cruiser alternative?
madge is the simplest: one command, and it fails the build on a cycle by default. Nx's `enforce-module-boundaries` rule covers cycles between Nx projects if you already use Nx. dependency-cruiser stays the best choice when you want custom rules about which folders may import which.
Do type-only imports count as circular dependencies?
Not at runtime: `import type` is erased when TypeScript compiles, so it cannot cause a half-initialised module. madge counts them unless you set `skipTypeImports`, and dependency-cruiser counts them unless the rule has `viaOnly: { dependencyTypesNot: ['type-only'] }`. Whether to allow them is a design choice; they still tie the two files together for anyone reading the code.
Why does Go say "import cycle not allowed in test"?
A test file in the same package is compiled into that package, so if it imports something that imports the package back, the package imports itself. Rename the test file's package to `<name>_test`. It becomes a separate package that imports yours like any other caller, and the cycle goes away.
How do I fail CI when a new circular dependency appears?
Use madge `--circular` as is, dependency-cruiser with `no-circular` set to `error`, or import-linter with an `acyclic_siblings` or `layers` contract. All three exit non-zero on a cycle. If you have existing cycles you cannot fix yet, dependency-cruiser and import-linter both support ignoring specific known imports so the check still catches new ones.
Can a circular dependency span two languages?
Yes, through a network call or a shared schema instead of an import: a Python service calls a TypeScript API that calls the Python service back. Import-based tools cannot see it. You find those by mapping which service calls which, from HTTP clients, queue topics and generated API clients.