# racesift: group and rank Go data race reports from go test -race
[](https://github.com/GillMacedo/racesift/actions/workflows/ci.yml) [](LICENSE)
**A Claude Code skill and Go CLI for Go developers whose `go test -race` run or CI log is full of `WARNING: DATA RACE` reports. It groups repeated reports into distinct races, ranks them by how many tests hit them, shows both code locations and the tests involved, and adds hints for common causes like an unguarded map or a mixed atomic. It can also compare two runs to show races a branch added.**
The race detector prints a 30-line report for every race it sees, and one missing lock can produce dozens of reports across many tests. `racesift` reads that output and gives each race once: the two lines of your code that conflict, where each goroutine started, and which tests hit it. The skill then has Claude read both locations, name the shared variable, and propose a fix.
## What you can ask Claude
- "CI failed with WARNING: DATA RACE. Here's the log. What's wrong?"
- "Which of these data races should I fix first?"
- "Does my branch add any new data races compared to main?"
- "Why does the race detector say my map is racing?"
## Install
As a Claude Code plugin:
claude plugin marketplace add GillMacedo/racesift
claude plugin install racesift@racesift-marketplace
Or as a CLI with Go 1.22 or newer. It has no dependencies:
go test -race ./... 2>&1 | go run github.com/GillMacedo/racesift/plugin/scripts@latest review
## Example
`examples/programs` is a made-up shop service with real races. Its test run printed 10 race reports, saved in `examples/logs/shop-v1.log`. From a clone of this repo:
$ go run ./plugin/scripts review examples/logs/shop-v1.log
4 distinct races from 10 reports, in 5 tests and 1 package
RACE 1 5 reports in TestCacheConcurrentRefresh, TestLoadAll, TestCacheWarmAndServe
write inventory.(*PriceCache).Set (inventory/cache.go:25)
via runtime.mapaccess2_faststr; started at inventory.TestCacheConcurrentRefresh (inventory/inventory_test.go:18)
read inventory.(*PriceCache).Get (inventory/cache.go:18)
via runtime.mapaccess1_faststr; started at inventory.TestCacheConcurrentRefresh (inventory/inventory_test.go:19)
RS101 A map is read and written by different goroutines.
Try: Guard the map with a sync.Mutex or sync.RWMutex, or use sync.Map if keys are mostly written once.
RACE 2 2 reports in TestLoadAll
read inventory.LoadAll.func1 (inventory/batch.go:25)
started at inventory.LoadAll (inventory/batch.go:18)
write inventory.LoadAll.func1 (inventory/batch.go:25)
started at inventory.LoadAll (inventory/batch.go:18)
RS103 A slice is appended to while another goroutine uses it.
Try: Guard the slice with a mutex, or give each goroutine its own slice and merge them after wg.Wait.
RS105 The same line races with itself, so it runs in several goroutines with nothing guarding it.
Try: Common causes are a shared counter, a field every worker sets, or a result variable. Use a mutex, an atomic, or a channel to collect results.
RACE 3 2 reports in TestReserve
write inventory.(*Audit).Record (inventory/audit.go:14)
via fmt.Fprintf; started at inventory.TestReserve (inventory/inventory_test.go:51)
write inventory.(*Audit).Record (inventory/audit.go:14)
via fmt.Fprintf; started at inventory.TestReserve (inventory/inventory_test.go:51)
RS104 Goroutines share one bytes.Buffer value. The race is inside bytes.(*Buffer).Write.
Try: Check the bytes.Buffer docs: most standard library types aren't safe for concurrent use. Guard it with a mutex or give each goroutine its own.
RS105 The same line races with itself, so it runs in several goroutines with nothing guarding it.
Try: Common causes are a shared counter, a field every worker sets, or a result variable. Use a mutex, an atomic, or a channel to collect results.
RACE 4 1 report in TestStats
read inventory.(*Stats).Snapshot (inventory/stats.go:15)
goroutine 16
write atomic.AddInt64 (<autogenerated>:1)
started at inventory.TestStats (inventory/inventory_test.go:40)
RS102 One side is an atomic operation and the other is a plain read or write.
Try: If your code calls sync/atomic, use it for every access, or switch to a type like atomic.Int64. If the atomic is inside sync.Once or a lock, the plain side is probably replacing or copying the value that holds it.
RS108 One side's stack has no frame from your code.
Try: Go's report lost the caller, which happens with frames inside the runtime and sync/atomic. Use the other side, and where this goroutine was started, to find it.
To see what a branch changed, run the tests on both and compare. Here `shop-v2.log` comes from a version that fixes the cache and adds a counter with no lock:
$ go run ./plugin/scripts compare examples/logs/shop-v1.log examples/logs/shop-v2.log
New: 1 Gone: 1 Still there: 3
New races:
RACE 1 1 report in TestReserve
read inventory.Reserve (inventory/reserve_v2.go:11)
started at inventory.TestReserve (inventory/inventory_test.go:51)
write inventory.Reserve (inventory/reserve_v2.go:11)
started at inventory.TestReserve (inventory/inventory_test.go:51)
RS105 The same line races with itself, so it runs in several goroutines with nothing guarding it.
Try: Common causes are a shared counter, a field every worker sets, or a result variable. Use a mutex, an atomic, or a channel to collect results.
Gone:
inventory.(*PriceCache).Set (inventory/cache.go:25) <-> inventory.(*PriceCache).Get (inventory/cache.go:18)
Still there:
inventory.LoadAll.func1 (inventory/batch.go:25) <-> inventory.LoadAll.func1 (inventory/batch.go:25)
inventory.(*Audit).Record (inventory/audit.go:14) <-> inventory.(*Audit).Record (inventory/audit.go:14)
atomic.AddInt64 (<autogenerated>:1) <-> inventory.(*Stats).Snapshot (inventory/stats.go:15)
On a real project: logrus's tests at the commit before `fbf9913`, which fixed a race in `Entry.Bytes`, with that commit's new test added. "Lines in more than one race" puts `Entry.Bytes` on the list, and that's the function the commit changed:
$ go run ./plugin/scripts review examples/logs/real/logrus-fbf9913-before.log | head -n 5
8 distinct races from 8 reports, in 1 test and 1 package
Lines in more than one race:
7 logrus_test.TestEntryLoggerMutationRace.func3 (logrus/entry_test.go:295)
2 logrus.(*Entry).Bytes (logrus/entry.go:93)
A run with no races prints one line:
$ go run ./plugin/scripts review examples/logs/healthy.log
No data race reports found.
## Usage
racesift review [--json] [--fail] [--top N] [FILE ...]
racesift compare [--json] [--fail] BASE HEAD
- Reads plain `go test -race` output, `-v` output, `go test -json` output, `go run -race` output from a program, and GitHub Actions logs with their timestamps. With no FILE, or with `-`, it reads stdin.
- `--top N` prints only the N highest-ranked races, and says how many it left out. `--fail` and `--json` still cover every race.
- `--json` prints the full result, including every frame of the first report in each group.
- `--fail` exits 1 when `review` finds races, or when `compare` finds new ones. Without it, both exit 0. Exit code 2 means bad usage or a file it couldn't read.
- `compare` matches races by the pair of functions involved, ignoring line numbers, so an edit elsewhere in a file doesn't make an old race look new. Two races in the same pair of functions count as one there.
## Hints
| ID | Finds |
|---|---|
| RS101 | A map read and written by different goroutines |
| RS102 | An atomic operation on one side and a plain access on the other |
| RS103 | A slice appended to while another goroutine uses it |
| RS104 | A standard library value, such as `bytes.Buffer` or `strings.Builder`, shared between goroutines |
| RS105 | The same line racing with itself, like a shared counter |
| RS106 | A function and a closure it started using the same variable |
| RS107 | Every frame from your code is in `_test.go` files |
| RS108 | One side has no frame from your code |
RS000 means a report was cut off or couldn't be read. Hints come from the stack's shape. Treat them as leads and read the code.
## Limits
- **It can only group what the race detector printed.** The race detector reports races that happen during a run. A clean run doesn't mean the code is race free, and the number of reports can change from run to run.
- **It doesn't name the variable.** Go's reports give addresses, not variable names. The skill has Claude read the code to find it.
- **Lost frames.** Go's reports sometimes drop the caller when the access is inside the runtime or `sync/atomic`. Those sides show a runtime frame and get `RS108`.
- **Test names are a best guess with `t.Parallel`.** Without `-json`, a report is matched to the test that the next "race detected during execution of test" line belongs to.
- **Grouping is by line.** Two races on one variable from different lines are separate races. "Lines in more than one race" helps join them.
- Logs hold file paths and function names from your code. `racesift` reads them locally and never uses the network, but Claude sends what it reads to its model.
## FAQ
**How do I read a Go data race report?** Each report has two accesses: the current one and a "Previous" one, from different goroutines, with no synchronization between them. The first frame from your code on each side is where to look. `racesift` pulls those out for you.
**Why does the race detector report the same race many times?** It reports each conflicting pair of accesses it sees, and the same code runs in many tests. `racesift` groups reports by their two code locations.
**How do I find which data races my PR introduced?** Save `go test -race` output from main and from your branch, then run `racesift compare --fail main.log branch.log` in CI.
**Why does `runtime.mapaccess` show up in my race report?** A map is being read and written by goroutines with no lock. That's RS101.
**Does it work with `go test -json` and gotestsum?** It reads `go test -json` output directly. It hasn't been tested on gotestsum's own output formats.
**Is it an MCP server?** No. It's a skill with one Go source file, so `go run` is all it needs.
## Tested on
- **Real race output from programs in `examples/programs/`:** plain, `-v`, `-json` and `go run -race` output on Go 1.27, plus plain output on Go 1.22. Every hint from RS101 to RS108 fired on real output there. The GitHub Actions log is that real output with timestamps added, since this was built without CI access.
- **Real projects:** logrus at the commit before `fbf9913` and zap at the commit before `7c80d7b`, each with the fix commit's new test, in `examples/logs/real/`. Both point at the code each fix changed.
- **Clean runs stay quiet:** the healthy example package, logrus at `fbf9913`, and logrus at `6ef0748`, its latest commit when tested, each print only "No data race reports found."
- **Integration tests** in `tests/` run `go test -race` on the examples fresh and check the result.
It hasn't been tested in a fresh Claude session, on Windows or macOS, on gotestsum output, or through `go run ...@latest`, which needs the published repo.
## Contributing
Contributions are welcome, from people and from people working with AI agents. See [CONTRIBUTING.md](CONTRIBUTING.md). Good first issues are [listed here](https://github.com/GillMacedo/racesift/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
### How pull requests work
1. **Open a PR.** Fork the repo, work on a branch, and keep it to one change. Write a title that says what changes for users, because it becomes the changelog line: `feat:` goes under Added and `fix:` under Fixed.
2. **Automatic check.** Code changes need tests, and `make test` must pass. Changes over 30 lines need a linked open issue (`Closes #<number>`), and a PR can have at most 800 changed lines. If something is missing, a comment tells you what to fix.
3. **Review.** Claude reviews each PR automatically. Code PRs also get a security review and are tested in a sandbox. The review then merges the PR or asks for changes. PRs that change CI, agent settings, npm or Python dependencies, vendored code or build files can't be merged this way, and a comment says so. The same goes for anything the security review doesn't clear.
4. **Release.** A merge that changes what users install becomes a new version within about two hours, with a GitHub Release and, if the project has one, its npm package. Docs and CI changes wait for the next release.
## AI disclosure
This project was largely generated with Claude Code and reviewed by its maintainer.
## About me
I'm Giulliano Macedo, and I build developer tools at GoTech Solutions. Follow along or get in touch:
- GitHub: [GillMacedo](https://github.com/GillMacedo)
- LinkedIn: [giulliano](https://www.linkedin.com/in/giulliano/)
- X: [@giumacedo](https://x.com/giumacedo)
If `racesift` turned a wall of race reports into a short list, a star helps others find it.
---
Built by [Giulliano Macedo](https://github.com/GillMacedo), GoTech Solutions · [LinkedIn](https://www.linkedin.com/in/giulliano/) · [X](https://x.com/giumacedo)