CLI tool
tach-org/tach avatar
tach-org/tach

Tach: Enforcing Python Module Boundaries with a Rust Binary

A Python tool to visualize + enforce dependencies, using modular architecture 🌎 Open source 🐍 Installable via pip 🔧 Able to be adopted incrementally - ⚡ Implemented with no runtime impact ♾️ Interoperable with your existing systems 🦀 Written in rust

2,816 stars91 forksRustMIT

At a glance

What is it?
Tach reads a tach.toml file to declare which Python modules may depend on which, then fails the build when an import breaks the rule. It is aimed at teams who want modular monolith discipline without a runtime dependency.
Who is it for?
Adopt Tach if you have a Python package or monorepo where module boundaries exist on paper but nothing fails when they are crossed, and you want that enforcement in CI at zero runtime cost. Do not adopt it if you need cross-language dependency rules, or if you cannot commit to maintaining tach.toml as modules move: the configuration is the contract, and a stale contract produces false errors.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 1 day ago.
What is it written in?
Mainly Rust, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on September 15, 2026, and from our analysis. They are not legal advice.

DEEP OPEN-SOURCE ANALYSIS

The problem Tach addresses: imports that cross boundaries nothing checks

Python has no compiler-level notion of a module boundary. Any file can import any other file that is on the path, so a package that was designed as a set of layers or feature modules drifts one import at a time. The README frames Tach around the modular monolith idea, and the three things it says it can enforce are narrow and concrete: imports only come from declared dependencies, cross-module calls use the public interface, and there are no cycles in the dependency graph. Each of those is a property you can state in a config file and check mechanically. Tach is for the person who already believes in those properties but has no mechanism that rejects a pull request when they are violated. It is not a linter for style, and it is not a packaging tool. It reads your imports and compares them against a declared graph.

How the check works: a tach.toml contract compared against parsed imports

The mechanism is a declared graph checked against an observed one. You write modules and their depends_on entries into tach.toml, and tach check walks the source, resolves imports, and reports any import whose source module and target module are not connected by an allowed edge. The README gives the exact error shape: a file path with a line number, the offending import, and a sentence naming the two modules and the rule that was broken. Errors exit non-zero, which is what makes the tool usable in CI and pre-commit. The second enforcement layer is the interface: a module can expose a public surface, and cross-module calls are expected to go through it rather than reaching into internals. The third is acyclicity, which is a property of the graph rather than of a single import, so it cannot be caught by reading one file at a time. Because the checker is a Rust binary shipped through pip, the analysis happens outside the Python runtime. That is the source of the no runtime impact claim: nothing is imported into your process, and nothing is monkeypatched.

Getting from nothing to a passing tach check

Installation is a single pip command. Setup is interactive rather than file-first: tach init opens a file tree interface where arrow keys navigate and Enter marks a module boundary. The README suggests marking all top-level Python packages, or just the few you want to track. The 's' key marks source roots, which matters when your Python lives below the project root or when a monorepo holds several Python packages. After that, tach check from the project root is the enforcement step, and the success output is a single line confirming all modules validated. The README also describes a deliberate way to confirm the tool is actually doing something: remove an entry from depends_on in tach.toml, or add an import between two modules that did not previously reference each other, then run the check again and read the resulting error. That is a good first exercise because it proves the config is load-bearing rather than decorative. Beyond the check, tach show renders the dependency graph, and running it without flags writes tach_module_graph.dot in GraphViz DOT format to the working directory. The --web variant sends the contents of tach.toml to a remote service to render the graph, which is a detail worth noticing before you run it on a private repository.

Incremental adoption, interfaces and the tach.toml surface area

The README lists unchecked modules as the incremental adoption path, which is the honest answer to the obvious objection that a large codebase will produce hundreds of errors on day one. You can bring modules under enforcement one at a time instead of declaring the whole graph at once. Layered architecture is supported as a higher-level way to express ordering constraints, and individual dependencies can be marked deprecated, which gives a migration path for an edge you want to remove but cannot remove today. Domain ownership via tach-domain.toml suggests the config can be split so that teams own their own slice rather than fighting over one file. Inline ignore comments exist for the cases where a violation is intentional. Taken together, the configuration is the product: the checking logic is simple, and the expressiveness lives in what you can declare. That is also the cost. Every module you add, rename or move is a config edit, and nothing in the material suggests Tach generates or repairs tach.toml for you after the initial interactive pass.

Where Tach gets in the way

The failure mode to expect is configuration drift, not a missed violation. If tach.toml says a module depends on something it no longer imports, you get a stale contract that permits imports you meant to forbid. If it omits a dependency that the code legitimately needs, you get a red build on a change that is architecturally fine, and the fastest fix is to widen the config, which erodes the boundary. This is a real tax on any repository where module layout changes often, and the README does not describe a reconcile or autofix command. A second limitation is scope: the enforcement is about Python imports. If your architecture spans a Python service and a Go service, or if the coupling you care about is through a database schema or a message bus, Tach has nothing to say about it. Third, the remote rendering path for tach show --web means your dependency graph leaves your machine, which some teams will not accept for private code. None of these make the tool wrong; they define the shape of repository it suits.

Tach compared with import-linter

The closest widely used alternative in the Python ecosystem is import-linter, which also reads a declared contract and fails when imports violate it. The difference in approach is where the configuration lives and how the work is executed. import-linter is a Python package that you configure in setup.cfg or pyproject.toml and run as a Python entry point, so its contracts are expressed in Python-side configuration alongside your other tooling. Tach keeps its configuration in a separate tach.toml, ships a Rust binary, and adds an interactive init step that inspects your tree and proposes module boundaries rather than requiring you to write the contract from scratch. Tach also ships graph output and a report command that lists both dependencies of a path and usages of it, which is useful when you are deciding where a boundary should go. If your team already has import-linter contracts working, there is no strong reason in this material to migrate. Tach is more interesting when you are starting from nothing and want the tool to help you draw the first version of the graph.

Maintenance cost, licence and what to check before committing

Tach is MIT licensed, which is permissive and places few obligations beyond retaining the licence notice; that is a statement about the licence text, not legal advice, and your organisation should apply its own policy. The ongoing cost is the config file. Every new top-level package, every module split, and every intentional exception is an edit to tach.toml or to an inline ignore comment, and those edits belong in the same pull request as the code change that caused them. Release cadence visible in the supplied material shows v0.35.0 in May 2026, v0.34.1 in April and v0.34.0 in March, so the project is moving at a regular clip and you should expect to keep the pinned version current rather than freezing it for years. Before adopting, run tach init on a branch and inspect the generated tach.toml against your intended architecture, because the tree interface asks you to mark boundaries and it will happily accept a boundary that does not match how the code is actually organised. Then run tach check and count the errors. That number is your migration budget, and it is the single most useful thing to know before you propose this to a team.

Editorial conclusion

Adopt Tach if you have a Python package or monorepo where module boundaries exist on paper but nothing fails when they are crossed, and you want that enforcement in CI at zero runtime cost. Do not adopt it if you need cross-language dependency rules, or if you cannot commit to maintaining tach.toml as modules move: the configuration is the contract, and a stale contract produces false errors. Before rolling it out, run tach init on a branch, then tach show to read the graph it infers, and confirm that the depends_on entries match the boundaries you actually intend rather than the ones the tree walker guessed.

Official sources

  1. License: MIT
  2. Project website
  3. README
  4. Releases
  5. tach-org/tach on GitHub
Community notes

Community notes