Inside hypermatch: matching events against 100,000 rules without slowing down

Sep 17, 2026 · 11 min read

Every event-driven system has to answer the same question millions of times a day: which of my rules care about this event? An alert has to reach the right team, a price change the users who subscribed to it, a login from outside the company network the security team. With ten rules, a loop does the job. With a hundred thousand, the loop is the bottleneck.

I started hypermatch at Schwarz Digits for a group-wide alerting platform. It was built to bring the alerts of all our monitoring systems together and to trigger actions from them, with every alert checked against the rules of every team, at millions of events. None of the libraries I found fit that job, so I wrote one in Go and open-sourced it.

You give hypermatch rules, as JSON or as Go values, and events, and it tells you which rules match. On a single core of an Apple M4 Max, typical events are matched against 100,000 rules in well under a microsecond, and the time hardly depends on how many rules there are.

What a rule looks like

An event matches a rule if it matches every condition of the rule:

{
  "status":    {"equals": "firing"},
  "severity":  {"anyOf": [{"equals": "critical"}, {"equals": "warning"}]},
  "service":   {"prefix": "checkout-"},
  "labels.*":  {"equals": "production"},
  "source.ip": {"anythingBut": [{"cidr": "10.0.0.0/8"}]}
}

The patterns cover what rules need in practice: equals, prefix, suffix, wildcards, numeric comparisons and ranges, IP networks, exists, and anyOf, allOf, anythingBut and $or, nested as deeply as you like. labels.* looks at every field below labels, whatever it is called. In Go, a rule is a few lines:

hm := hypermatch.New[string]()
err := hm.AddRule("page-checkout-team", hypermatch.ConditionSet{
    hypermatch.Cond("status", hypermatch.Equals("firing")),
    hypermatch.Cond("severity", hypermatch.AnyOf(hypermatch.Equals("critical"), hypermatch.Equals("warning"))),
    hypermatch.Cond("service", hypermatch.Prefix("checkout-")),
})
if err != nil {
    log.Fatal(err)
}

matches, err := hm.MatchJSON(event) // for example [page-checkout-team]

The speed comes from a handful of ideas.

Sharing conditions between rules

The naive approach checks every rule against every event, so its cost grows with every rule you add. hypermatch compiles all rules into one shared structure instead.

First, hypermatch normalizes every condition. {"anyOf": [{"equals": "b"}, {"equals": "a"}, {"equals": "a"}]} and {"anyOf": [{"equals": "a"}, {"equals": "b"}]} are the same condition, so they get the same key. The wildcard abc* is just a prefix, so it becomes one.

Then it sorts the conditions of each rule by path and inserts the rule into a trie whose edges are conditions. Rules that begin with the same conditions share the same path through the trie, and their common conditions exist only once. If 50,000 rules contain "env": {"equals": "prod"}, that condition is checked once per event, not 50,000 times. Matching walks the trie, visits every state at most once, and follows only the conditions that hold.

For example, three rules whose conditions are sorted by path:

rule A: env=prod, service=checkout, severity=critical
rule B: env=prod, service=checkout, team=payments
rule C: env=prod, service=search

env=prod ─┬─ service=checkout ─┬─ severity=critical → A
          │                    └─ team=payments     → B
          └─ service=search                         → C

env=prod is checked once for all three rules. Both service conditions after it sit in one group, so a single hash lookup of the event’s service decides which branch to follow.

Looking values up instead of comparing them

At each state of the trie, all conditions on the same path form a group, and the group keeps one index over all of their patterns. Each value of an event is looked up in that index once, no matter how many conditions the group has:

  • equals is one hash lookup.
  • prefix and suffix take one hash lookup per distinct pattern length.
  • Numeric comparisons and ranges sit in sorted lists and are found by binary search, so 10,000 ranges on one field cost a handful of comparisons.
  • cidr takes one hash lookup per distinct prefix length: the address is cut down to /24, /16 and so on, and each result is looked up.
  • Other wildcards go into a trie of their tokens, which is run as an automaton.

The lookups return the patterns that match, and only the conditions attached to them become candidates. A condition that no value matched is never touched.

Running the automaton instead of building it

Wildcards are where rule engines tend to struggle. A common technique turns the combined patterns into a deterministic automaton, which runs fast, but whose number of states can multiply as more patterns are added. hypermatch keeps the automaton nondeterministic and simulates it: it tracks the set of states the input can be in, byte by byte. Together with the sharing above, 100,000 rules with the same *-myapp-* wildcard use a single automaton, and 100,000 different wildcards stay fast as well.

Letting the event rule out exclusions

anythingBut is the odd one out. It holds when nothing matches, so no lookup can trigger it, and it has to be considered for every event that contains its path. Many different exclusions on the same field used to be evaluated one by one.

Since version 2.3, the values of the event do that work. An exclusion like {"anythingBut": [{"equals": "moon"}, {"equals": "mars"}]} holds exactly when neither moon nor mars matched. So hypermatch registers it with those two patterns, and a match on either one marks the exclusion as ruled out in a bit set. Every exclusion without a mark holds, and nothing has to be evaluated.

For example, with three exclusions on region and an event whose region is moon:

exclusions on "region"          event: region = moon
#0  anythingBut moon, mars      ruled out by moon
#1  anythingBut moon, venus     ruled out by moon
#2  anythingBut pluto           no mark, holds

A mark is one bit per exclusion, so ruling out a hundred exclusions costs a hundred bit operations instead of as many evaluations. Finding the ones that hold is a scan over a few 64-bit words.

When most exclusions are ruled out because the event contains a value they exclude, this made matching about twice as fast, without using more memory.

Conditions that need real logic, such as an allOf inside an anythingBut, are compiled into small formulas and evaluated against a bit set of the patterns that matched.

Changing rules while events flow

Rules change while events keep flowing: a user subscribes, an on-call rotation moves on. hypermatch lets AddRule, ReplaceRule and RemoveRule run at any time, and Match never takes a lock.

Writers take turns, and they only ever publish complete data. A new list element is written before the length of the list grows, a pattern is in the index before the condition that uses it appears, and everything is published through atomic pointers. A reader therefore sees the rules before a change or after it, never halfway.

The smallest building block is an append-only list that readers use without a lock. Its whole trick is the order of a few lines:

// A reader loads the length first, then the array.
n := l.n.Load()
if n == 0 {
    return nil
}
return (*l.data.Load())[:n]

// The writer fills the next slot first, then publishes the new length.
data[n] = v
l.n.Store(int32(n + 1))

Whichever array the reader gets holds at least n elements: either the one published with that length, or a larger copy that the writer made when the list was full and published before writing the new element. The writer never touches those elements again. The whole engine is built from structures like this: lists, hash maps and bit sets whose writers publish in an order that readers can rely on.

ReplaceRule is atomic through a version number that every match reads first, so each match sees either the old rule or the new one. Once enough rules have been removed, a writer builds a compacted copy and swaps it in with a single pointer, while running matches finish on the old one.

Because nothing blocks, throughput grows with the number of cores: on 14 cores, the first benchmark in the table below matches well over ten million events per second.

Avoiding allocations, and decoding only what a rule asks for

Each match borrows its scratch buffers from a pool, so AppendMatches does not allocate at all once it is warm. MatchJSON comes with its own JSON scanner, which validates the whole document but decodes only the values of paths that some rule refers to, case-folds them in place and skips everything else. On that same benchmark, this is more than three times as fast as json.Unmarshal followed by Match.

Matching IP networks runs into Go’s address parser, which allocates an error for every value that is not an address, and log fields are full of values like - or unknown. hypermatch therefore parses addresses itself, without allocating, and a fuzz test checks the parser against Go’s own net/netip.

A compiled rule takes 300 to 450 bytes, depending on its patterns.

How I know it’s correct

Fast and wrong is worthless in a rule engine: a missed rule is a missed alert. That is why the test suite checks hypermatch against a second, deliberately simple implementation of the documented semantics, which just loops over every rule and every value.

  • Differential tests generate thousands of random rule sets and events from a small alphabet, so that rules overlap a lot, and compare every result with the simple implementation. The alphabet contains the nasty cases: upper case, non-ASCII letters, the Kelvin sign, invalid UTF-8, escaped wildcards and IPv4 addresses written as IPv6.
  • Fuzzing does the same with inputs that Go’s fuzzer mutates: for rules and events, for JSON documents, and for the IP address parser.
  • Race tests add rules while readers match, and fail as soon as a reader sees a rule match that does not hold.
  • Mutation tests break the code on purpose, for example by flipping a comparison or dropping a line, and check that some test fails. Wherever none did, a test was missing, and I wrote it.

The numbers

Every change to the matching path is measured against its predecessor in interleaved A/B runs. A case-sensitive matching option that cost 2 to 4 percent on one workload never shipped.

In-package benchmarks with 100,000 rules each, on one core of an Apple M4 Max:

WorkloadTime per event
6 conditions of different pattern types, 10 rules match each event0.54 µs
2 equals conditions0.17 µs
a different URL prefix per rule0.20 µs
a different IP network per rule0.15 µs
a value among all labels, with labels.*0.23 µs

For context, I ran the same 100,000 rules and the same JSON events through two other libraries: quamina, a Go library, and AWS Event Ruler, the Java library behind Amazon EventBridge.

hypermatch
1,473,252
AWS Event Ruler
281,148
quamina
32,612
Events matched per second against the same 100,000 rules, on one core of an Apple M4 Max. Go 1.26, Event Ruler 2.2.0 on OpenJDK 21, quamina 1.5.1.
LibraryEvents per secondMemory per rule
hypermatch (MatchJSON)1,473,252394 B
AWS Event Ruler281,1481,805 B
quamina32,61213,590 B

That is about five times the throughput of Event Ruler, with less than a quarter of its memory per rule. Event Ruler ran on the JVM and matched events for five seconds before the measurement, so its JIT had compiled everything. The absolute numbers belong to this machine, and the ratios are what counts. The benchmark is part of the repository, so you can run it yourself.

With a wildcard in every rule, the gap widens: hypermatch keeps about three quarters of its throughput, quamina drops to 5 events per second, and Event Ruler did not finish building the 100,000 rules within 25 minutes. Rule sets like that are rare, but they show what running the automaton instead of building it is worth.

Where it fits

  • Alert routing: send each alert to the right team, channel or escalation, based on severity, service and labels.
  • Subscriptions: let users filter events themselves, from price alerts to “tell me about new issues labeled bug”, hundreds of thousands of them.
  • Feature flags and targeting: decide from a user’s properties who gets a feature.
  • IoT and telemetry: catch sensor readings outside their normal range, per device type or site.
  • Security and audit logs: flag access to sensitive paths, or logins from outside your networks.
  • Content-based routing: route orders, tickets or documents to the services responsible for them.

Try it

go get github.com/SchwarzDigits/hypermatch/v2

hypermatch has no dependencies beyond the Go standard library and is licensed under Apache 2.0. The README covers every pattern, and the runnable examples show alert routing, subscriptions, feature targeting and security logs in code.

If you route, filter or subscribe to events in Go, I’d like to hear what you build with it, and what you would need next.