Blog · 4 min read
You'd expect \h to match spaces. In Ruby, it matches hex digits.
Kristof Polleunis · August 19, 2026
Here is a regex. What does it match?
"DEAD 00FF xyz".scan(/\h+/)
In Ruby: ["DEAD", "00FF"] — and xyz is left out, because \h is a
hex digit in Onigmo, Ruby's regex engine — equivalent to
[0-9A-Fa-f], and x, y, z don't qualify.
Now paste that same pattern into almost any online regex tester. Most of
them run PCRE or a JavaScript engine. In PCRE2, \h is horizontal
whitespace. Your two hex chunks become two spaces.
No error. No warning. Just a different answer.
This is the normal case, not an edge case
I build a regex tool for macOS, and the reason it bundles fourteen native engines instead of approximating everything with one is that the approximation is wrong often enough to ship bugs.
\h is the cleanest example because it's not obscure syntax — it's two
characters, and the two most common engines a Ruby developer will paste
into disagree completely about what they mean.
The list goes on:
\binside a character class. In several engines[\b]is a backspace character, not a word boundary. Outside the class it's a word boundary. Same two characters, different meaning depending on position.scanand trailing empty matches. Ruby'sString#scanemits a trailing empty match in cases most engines don't. If your tool approximatesscanwith a generic global-match loop, your match count is off by one at the end of the string — but only sometimes.- Unicode property shorthand.
\p{Alpha}and friends resolve differently between Onigmo and PCRE2 for the same input.
Each of these is silent. That's the problem. A regex that returns the wrong matches doesn't throw — it just quietly does the wrong thing in production, three days after your tests passed.
Why I stopped approximating
The first version bundled CRuby. Actual MRI, shelled out to, so the regex ran on the same engine your application uses. It worked and it was honest, and it cost about 12 MB of download and a slow interpreter bootstrap on every call.
That's a lot of weight to carry for one feature.
The second version is 820 KB.
The realisation was that I never needed Ruby. I needed Onigmo, the regex engine Ruby happens to embed. Onigmo is a standalone C library. Ruby is one of its consumers, not its owner.
So: vendor the Onigmo 6.2.0 C sources, compile them, link statically,
and expose a thin binary that speaks JSON over stdin/stdout. No
interpreter, no bootstrap, no $LOAD_PATH, no gems. The same engine, the
same match offsets, one twelfth of the size.
Two bugs worth knowing about
Binding directly to a regex engine's C library gets you the engine's matching behaviour for free. It does not get you the host language's iteration behaviour, and that turns out to be where the bodies are buried.
Advancing the cursor. The naive global-match loop advances from where
you started searching. Onigmo — and therefore Ruby — advances based on the
span of the match you just found. For matches that consume no characters — a lone \b, an empty
alternative — those two rules produce different results, and the
difference only shows up on patterns that can match empty.
Trailing empty matches. scan emits one at the end of the string in
cases a generic loop does not. It looks like an off-by-one bug in your
code. It is not; it is Ruby being consistent with itself, and any tool
claiming to show you Ruby behaviour has to reproduce it.
Both bugs shipped before I caught them. Both were found by running the same pattern through real Ruby and diffing, which is the only technique I trust for this.
The test that guards it
The build script smoke-tests exactly one thing before it will produce a binary:
\h+ against "abc 123 def4"
If the result is three hex chunks, Onigmo is servicing the pattern. If it's spaces, PCRE2 leaked in somewhere and the build fails.
One assertion, two characters, and it catches an entire class of "we accidentally shipped the wrong engine" regressions.
What to take from this
If you're testing Ruby regexes in a browser tool, check what engine it
actually runs. Most say "Ruby" and mean "PCRE with the label changed."
For most patterns you'll never notice. For \h, [\b], \p{...}, and
anything involving zero-width matches, you will — eventually, in
production.
The safest test for Ruby regex remains irb. Everything else is a
convenience that may or may not be telling you the truth.
I maintain RegexPilot, a macOS regex editor that bundles Onigmo and thirteen other engines so patterns run on the real interpreter for their language. The Onigmo binding described here is what backs its Ruby flavour.
More from this series: