# Tach: Enforce Python Module Boundaries with a Rust-Powered Checker

> Tach is a pip-installable tool that reads a tach.toml file and fails your build when imports cross module boundaries you did not declare. Here is how the setup flow, the checker and the graph output actually work, and where the approach runs out of road.

**tach-org/tach** — 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

- Repository: https://github.com/tach-org/tach
- Website: https://docs.gauge.sh/
- Stars: 2,825 · Forks: 92
- Language: Rust
- License: MIT
- Published: 2026-09-10 · Updated: 2026-09-10 · Language: en
- Canonical page: https://hysenlabs.com/projects/tach-org-tach

## The problem Tach targets: imports nobody agreed to

Python lets any module import any other module. Nothing in the language stops a helper file three directories deep from reaching into a sibling package's internals, and nothing warns you when a change to that sibling breaks the caller. In a small project this is invisible. In a package with a dozen subpackages and several contributors, the import graph becomes the real architecture, whether or not anyone designed it that way.

Tach's answer is to make the intended graph explicit and then check code against it. The README states that Tach can enforce three things: imports only come from declared dependencies, cross-module calls use the public interface, and there are no cycles in the dependency graph. That is a narrow, checkable contract rather than a general linting framework. The audience is Python teams who already think in terms of modules or packages and want the boundary written down somewhere a machine can read.

The project describes itself as inspired by the modular monolith architecture, which is the right frame. It is not a microservice splitter and it does not move code. It is a guard rail around the structure you already have.

## How tach check turns tach.toml into a pass or fail

The mechanism is a config file plus a static check. tach init walks you through a file tree interface and writes tach.toml, recording which directories are modules and what each may depend on. From then on, tach check parses your Python source, resolves each import to a module, and compares it against the declared edges.

The README gives the error format directly: `tach/check.py[L8]: Cannot use 'tach.filesystem'. Module 'tach' cannot depend on 'tach.filesystem'.` So the output names the file, the line, the imported target and the rule that was broken. The README also notes that when the terminal supports hyperlinks the file path is clickable. On any violation the command exits non-zero, which is what makes it usable in CI and pre-commit hooks.

The parsing layer is not written in Python. The Cargo.toml lists pyo3 alongside ruff_python_ast, ruff_python_parser, ruff_linter, ruff_source_file and ruff_text_size, pulled from the Ruff repository at tag 0.16.6. In other words, Tach reuses Ruff's Python parser and AST machinery from Rust rather than shipping its own grammar. That is a real design decision: it means import resolution benefits from a parser that is already exercised heavily, and it means Tach's correctness is partly tied to the Ruff revision it pins. The pyproject.toml dependencies are the Python side of the same tool: pyyaml, tomli, tomli-w, rich, prompt-toolkit, GitPython, networkx and pydot. The interactive init flow uses prompt-toolkit and rich; the graph features use networkx and pydot.

One consequence of this split is worth stating plainly. The heavy lifting is compiled, so `tach check` has no import-time cost on your application. It is a separate process you run, not a library your code imports.

## Installing Tach and getting a first real check

Tach requires Python 3.10 or newer according to its pyproject.toml, and the README gives a single install command. Run it in the environment where you want the CLI available:

```bash
pip install tach
```

Then, from the root of your Python project, run the guided setup:

```bash
tach init
```

The README describes what happens next: an introductory message, then a file tree interface where you use the arrow keys to navigate and press Enter to mark each module boundary. You can mark all top-level Python packages or just a few. If your Python code sits below the project root, or you have a monorepo with several Python packages, the README says to mark source roots with the 's' key.

With tach.toml written, the enforcement command is:

```bash
tach check
```

On a clean tree the README shows `All modules validated!`. To confirm the check is actually doing something, the README suggests two experiments: remove an entry from the `depends_on` key in tach.toml, or add an import between two modules that did not previously import each other. Run `tach check` again and you should get the violation line shown above, plus a non-zero exit status.

Two more commands are worth knowing on day one. `tach show` generates a dependency graph, and running it without flags writes `tach_module_graph.dot` in GraphViz DOT format to your working directory. With `--web` the README states the graph is generated remotely from the contents of your tach.toml. And `tach report` prints dependencies and usages for a path, for example `tach report my_package/` or `tach report my_module.py`, listing each import with file and line number.

## Where the boundary model breaks down

The model assumes modules map onto importable paths. The README's own FAQ link, "What is a module?", exists because that mapping is not always obvious, and the source-roots setting exists because monorepos and namespace packages do not fit the simple case. If your codebase has dynamic imports, plugin loading through entry points, or imports constructed at runtime from strings, a static AST check will not see them. Tach cannot fail a build on an edge it cannot parse.

There is also a config-drift risk that is inherent to the design. tach.toml records the graph you declared, not the graph you want. If someone adds a new `depends_on` entry to silence a violation, the check passes and the architecture has quietly changed. Tach's deprecate feature, which the README links under "Deprecating individual dependencies", is the intended answer to that, but it still relies on a human choosing to use it.

The package metadata lists Development Status as 4 - Beta. That is the project's own classifier, and it should shape how you treat the config format across upgrades. Treat tach.toml as something to review in pull requests the same way you review code, because it is the thing that decides what passes.

Finally, Tach is the wrong tool if what you actually want is runtime isolation. It does not prevent an import at execution time, sandbox a module, or change how Python resolves names. It reports on source code before it runs.

## How Tach differs from a general-purpose linter

Ruff is the obvious comparison, and the relationship is closer than a typical competitor pairing. Tach's Rust crate depends on ruff_python_ast, ruff_python_parser, ruff_linter and related crates at tag 0.16.6, so Tach is partly built on Ruff's parsing stack. The difference is in what each tool is configured to know.

Ruff's import-related rules operate on individual import statements and files: this import is unused, this import is in the wrong order, this module shadows a builtin. They do not carry a project-wide model of which package may depend on which. Tach's unit of configuration is the module and the declared edge between modules, and its output is a graph-wide verdict rather than a per-file diagnostic. Running both is normal; they answer different questions, and the README's own CI badge shows Tach checked by Ruff's ecosystem tooling elsewhere in the repository.

Against a hand-written import-linter configuration, the practical difference is the setup path. Tach ships an interactive `tach init` that walks a file tree and writes the config for you, plus `tach show` for a visual graph and `tach report` for per-path dependency and usage listings. That is a lower starting cost than authoring boundary contracts from scratch, at the price of accepting Tach's module and source-root model as the vocabulary for your boundaries.

## Adoption, maintenance and the MIT licence

The repository is not archived, and the last push was on 2026-09-08, so the project is receiving commits. The most recent release listed is v0.35.0 from 2026-05-12, with v0.34.1 and v0.34.0 before it. The pyproject.toml in the repository carries version 0.35.1, which is ahead of the last tagged release in the repository. The gap between the last push and the last release is a normal pattern for a project that merges work continuously, but it does mean the changelog is not the only place to look when you want to know what changed.

Upgrade cost is mostly the config format. Tach is distributed as a pip package with a compiled extension, so a version bump is a `pip install --upgrade tach` away, but the file that governs your build is tach.toml and its schema is what your CI depends on. The README does not document rollback or a config migration path, so a version bump that changes config semantics would surface as a failing `tach check` rather than a guided migration. Pin the version in CI and read the release notes before moving.

The licence is MIT, per the repository metadata and the PyPI licence badge in the README. MIT permits commercial and closed-source use with the licence and copyright notice retained. That is a permissive arrangement with few obligations, but it is not legal advice and the LICENSE file is the authoritative text.

## Conclusion

Adopt Tach if you have a Python package whose import graph has quietly become a thicket and you want a config file, not a refactor, as the first step. Skip it if your problem is a single flat top-level package with no internal boundaries to name, or if you need enforcement at runtime rather than in CI. Before committing, run tach init, then tach check on a clean tree, then deliberately break one depends_on entry and confirm the non-zero exit code reaches your pipeline.

## FAQ

### How do I install Tach?

Install it with pip: `pip install tach`. It requires Python 3.10 or newer according to the package metadata. After installing, run `tach init` from your project root to generate the configuration.

### Does Tach affect my application at runtime?

No. The README describes Tach as implemented with no runtime impact; the checker is a separate CLI process you run, with the parsing and checking logic compiled in Rust. Your application code does not import Tach.

### What file does Tach use to know which modules may depend on each other?

Tach reads tach.toml, which `tach init` writes for you through an interactive file tree interface. The `depends_on` key in that file records the allowed edges, and `tach check` compares your imports against it.

## Sources

- [License: MIT](https://github.com/tach-org/tach/blob/main/LICENSE)
- [Project website](https://docs.gauge.sh/)
- [README](https://github.com/tach-org/tach/blob/main/README.md)
- [Releases](https://github.com/tach-org/tach/releases)
- [tach-org/tach on GitHub](https://github.com/tach-org/tach)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/tach-org-tach
