Where Raku++ and Rakudo differ
Raku++ targets the same language as Rakudo and is measured against it: every runnable example in the official documentation is executed on both, and every Roast assertion is scored. 953 of 1,451 documentation examples produce byte-identical output on both engines, and ~90% of Roast's declared tests pass.
The two are not the same program, though, and this page is the honest list of where you will notice — in both directions.
Where Raku++ does something Rakudo does not #
It compiles to a standalone binary. --exe generates C++ and links it; the result needs no Raku installed, and with -O runs 4×–500× faster than the interpreter depending on the kernel — string building at the low end, tight integer loops at the high. See turning a program into a binary.
It runs in a browser. The same interpreter compiled to WebAssembly — no server, no install: https://raku.online.
It starts in ~3 ms. There is no runtime to boot. That is the difference between a Raku script being usable in a shell pipeline and not.
It ships a static analyser. rakupp --lint reports unused variables, unreachable code and redeclarations without running the program, and exits non-zero — so it drops into CI. See LINT.md.
It is one binary with no third-party dependencies.
It terminates on some inputs Rakudo does not. A string sequence whose endpoint .succ can never reach loops forever in Rakudo; Raku++ stops:
say ('a!' ... 'zz!').elems; # Raku++: 702. Rakudo: does not terminate.
That one is a deliberate divergence, not an accident — a compiler that hangs is worse than one that answers.
A more readable backtrace. Both engines name every frame of an uncaught error. Raku++ also shows the source line the error came from, names the exception type, folds runs of identical frames so a deep recursion does not fill the terminal, and puts a method's class in the frame (in method Foo::new, where Rakudo prints a bare in method new). See TRACER.md.
And a handful of documented behaviours it gets right where Rakudo has drifted. The conformance sweep classifies 19 examples as doc and Raku++ agree, Rakudo does not. Most are small — 1.asinh gives the documented 0.881373587019543 here and 0.8813735870195429 in Rakudo, for instance. A few are randomness or stale docs rather than real wins; the classified list lives in the spec site's source.
Where Rakudo is ahead #
Maturity and coverage. Rakudo is a complete, production implementation with two decades behind it. Raku++ passes ~90% of Roast; the remaining 10% is real language, and you will find it if you go looking.
Stricter parsing. Rakudo rejects some programs Raku++ accepts, a missing semicolon between statements among them. If you want your syntax checked strictly, Rakudo is the better checker.
The full ecosystem. Many zef modules work under Raku++, but not all; see ECOSYSTEM.md and MODULES.md.
Differences you will actually run into #
A file handle buffers; $*OUT and $*ERR do not. Both engines write every say straight out. They differ on opened handles: Rakudo writes those through too, while Raku++ holds back an 8 KiB block, so a file being written is not yet complete on disk until it is closed or flushed. It matters when something else is reading it live — a log being tailed. .out-buffer is the documented switch and works here as it does in Rakudo:
my $log = open $path, :w, :!out-buffer; # every write lands immediately
$log.flush; # or push out what is pending, once
The reason for the difference is that a Raku++ handle has no persistent descriptor behind it, so writing through costs an open/write/close per print. The same knob goes the other way on the standard handles — $*OUT.out-buffer = 65536 buys back the block for an output-heavy program, which Rakudo cannot do.
Proc::Async matches Rakudo now. Sinking a failed process's Proc throws the same message on both engines, a signal death reports exitcode 0 with .signal set on both, and :w with .print/.say/.close-stdin feeds the child. All three used to diverge and no longer do. See background processes and Proc::Async.
.out on a run you did not capture. Rakudo hands back a bare IO::Pipe type object, so reading it dies; Raku++ hands back an empty handle, so it reads as "". In both, the output went to your own stdout as the child produced it — pass :out if you want it.
Str.succ on a trailing non-alphanumeric. 'a!'.succ is 'b!' here and 'a!' in Rakudo. Deliberate: Rakudo's own source comments describe a rule its code does not implement, it ships a leftward-scanning helper matching ours that Str.succ does not call, and the spec tests pass under either reading. Ours also terminates where Rakudo's loops.
Multi-character string ranges. Rakudo iterates ('ab'..'ba') as a per-position cross product — ab aa bb ba; Raku++ climbs by .succ. Known, and on the list.
Hash iteration order. Raku++ iterates sorted; Rakudo's order is its own and varies. Neither is guaranteed by the language — sort if you depend on it.
lib, . and rakulib are already on the module search path. They are relative to the directory you run from, so a checkout finds its own lib/ without -I lib, and a program finds a module sitting beside it without -I .. Rakudo has none of the three: there, -I. is the explicit opt-in.
cd myproject && rakupp app.raku # finds lib/Helper.rakumod and ./Helper.rakumod
cd myproject && rakudo app.raku # Could not find Helper
cd myproject && rakudo -I. -Ilib app.raku # …now it does
Two consequences. A program written against Raku++ and never run elsewhere may be relying on this without knowing; pass -I explicitly in anything meant to be portable. And a module file in the directory you happen to be in takes precedence over an installed distribution of the same name — convenient when that is a working copy you are editing, and worth knowing about when it is not.
Both engines resolve a name to Foo.rakumod, Foo.pm6 or Foo.pm, and .pm goes away under use v6.e.PREVIEW. Neither resolves Foo.raku — .raku is a program's extension, not a module's — and on both, a distribution that does keep a module in a .raku file is loaded through the path its META6 provides names.
$*RAKU.compiler.version reports a Rakudo era, not the Raku++ release. It answers v2026.08 — the Rakudo release Raku++ is verified byte-identical against — while the rest of the object says who is actually running:
say $*RAKU.compiler.name; # → Raku++ (Rakudo says: rakudo)
say $*RAKU.compiler.version; # → v2026.08 (the era tracked, not our release)
say $*RAKU.compiler.release; # → 3.6.0 (Rakudo leaves this empty)
say $*RAKU.compiler.id; # → 3.6.0 (Rakudo: a commit SHA)
say $*RAKU.compiler.backend; # → cpp (Rakudo: moar)
say $*VM.name; # → cpp (Rakudo: moar)
say $*RAKU.VMnames; # → (cpp js) (Rakudo: (moar jvm js))
$*VM.name names the BACKEND, as Rakudo's does: cpp for the interpreter and --exe, js for --target=js. The one place Raku++ answers moar on its own is inside a build hook run by rakupp install — the ecosystem's build recipes gate on that name and have no other branch — and that dialect is scoped to the hook.
RAKUPP_VM_NAME lets you assert the dialect for a whole run. Some modules branch on $*VM.name and die on the else — LibraryMake answers Unknown VM; don't know how to build, uniprop Unexpected backend name: cpp — even though everything they then reach for works here. The variable makes that your call:
rakupp -e 'say $*VM.name' # → cpp
RAKUPP_VM_NAME=moar rakupp -e 'say $*VM.name' # → moar
RAKUPP_VM_NAME=moar rakupp -e 'use LibraryMake; say get-vars(".")<CC>' # → cc; without it, dies
It also joins $*RAKU.VMnames, so $*VM.name eq any($*RAKU.VMnames) still holds, and it moves $*RAKU.compiler.backend with it — Rakudo answers one string in both spellings, and a module reading the other one must not see a contradiction. Nothing else changes: $*VM.config already carries this engine's real toolchain (cc, ldshared, obj), and those values are what the recipe actually builds with. Nothing sets the variable for your programs, because the default is the honest answer, and reporting cpp as moar is a claim only the person running the program is entitled to make. The one thing that does is the installer, for a distribution's test suite that has already stopped on the name, and only for a second run of that suite. It is for the case where you have looked at a module and concluded its moar branch is the right one for this engine; it is not a compatibility mode, and a module that genuinely needs MoarVM will fail later rather than sooner. "Unknown VM", "Unexpected backend name: cpp", and RAKUPP_VM_NAME has the practical side: installing LibraryMake, which modules need the variable, and for how long.
Raku++ keeps $*VM, $*KERNEL and $*DISTRO in a Hash, so it also accepts $*VM<name>, where Rakudo dies with Type VM does not support associative indexing. Raku++ is the permissive one here; $*VM.name is the spelling that works on both.
Because .id is our release rather than Rakudo's per-build hash, two different builds of the same release are indistinguishable there. Raku++ adds the missing identity as its own pair of keys, which Rakudo has neither of:
say $*RAKU.compiler.build; # → v3.14.0-74-g9ff47ae (git describe)
say $*RAKU.compiler.build-date; # → 2026-08-16 (UTC, at build time)
.build reads as 74 commits past the v3.14.0 tag, at commit 9ff47ae, and gains a -modified suffix when the tree had uncommitted changes; on a tagged commit it is just the tag. The version in it is always the release the binary reports: when no tag for that release is reachable — a release tagged on a line that was later rebased, say — the count is dropped rather than borrowed from an older tag, and the string is v3.20.1-ga5b4aa8. Built from a source tarball, with no .git to ask, .build is unknown rather than a guess — .build-date is still stamped. Quote .build in bug reports: it is the only thing that pins the exact binary.
This is deliberate, and it reverses an earlier decision to report our own version there. Modules gate features on $*RAKU.compiler.version < v2023.12, using the compiler version as a proxy for do I have modern semantics? — answering v1.7.0 compares as a pre-2000 Rakudo, and every such module refuses to load. JSON::Class was the witness.
So: detect the engine with .name, not with .version. The number answers what the language does; the name answers who implements it. The era constant is kOracleEra in src/Interpreter.h, and it moves when the conformance oracle moves — not when Raku++ is released.
The 6.e revision #
Both engines implement 6.e behind use v6.e.PREVIEW, and — for the ~50 changes tracked at raku.online/spec/6e — they now agree on what it does. Where they do not agree is on who the revision belongs to.
In Raku++ it belongs to the code: a module compiled under the pragma keeps 6.e semantics when a 6.d program calls it, and a 6.d program is not changed by loading such a module. Rakudo loads CORE.e into the process instead, so this prints 0+2i twice — including on the line above the use:
say (-4).sqrt; # Rakudo: 0+2i Raku++: NaN
use SomeSixEModule; # a module written under `use v6.e.PREVIEW`
say (-4).sqrt; # Rakudo: 0+2i Raku++: NaN
If you write 6.e modules for a 6.d program, do not rely on either shape: pass values, not semantics.
Two smaller ones, both cases of us following the documented meaning where Rakudo does not:
.indices($needle, :smartcase)honours the adverb here."hello Hello".indices("hello", :smartcase)is(0, 6); in Rakudo the:smartcasecandidate is never dispatched to and the answer is(0,), while:ion the same call gives both positions.- Three 6.e-adjacent things stay ours to fix, and are not revision-specific: a block accepts extra positionals in any revision (
my &c = { 42 }; c(1,2,3)runs here and dies there),Instant.from-posix(0)is ten seconds off, and.subparseon a failed match answersNilwhere Rakudo answers with the grammar object.
Keeping yourself portable #
If a program must run on both, the reliable habits are:
awaitaPromise.allof/anyofbefore asking its.status. Raku++ keeps the combined promise eagerly, so it readsKeptimmediately; Rakudo hands it to the scheduler and readsPlanneduntil that runs. After anawaitboth sayKept, which is what real code does anyway- write
callsame(), not barecallsame, when its result feeds an operator:"[" ~ callsame ~ "]"parses on Raku++ but Rakudo reads it ascallsame(~"]")and rejects it sortbefore comparing anything that came out of a hash- pass
:outwhen you capture, rather than relying on pass-through timing - decontainerise explicitly —
@(…)— rather than relying on a coercion - diff the two engines on your own test data; it takes one line and finds these before your users do
The measured, per-example classification is in the spec site's source; the Roast standing and how it is counted are in ROAST.md and COUNTING.md.
Back to the FAQ index.