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 ~2ms. 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 && raku app.raku # Could not find Helper
cd myproject && raku -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 test LibraryMake # passes; without it, does not
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 you — not rakupp install, not the test runner — 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. 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.
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/Builtins.cpp, 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:
- put a
;between statements even when the first one ends in}. Raku supplies an implicit statement separator only when the}is the last thing on the line, sosub f { … } say 1;needs one. Raku++ accepts it without; Rakudo says "Strange text after block (missing semicolon or comma?)". Rakudo is right, and this is the dangerous direction of divergence — the permissive engine teaches a habit that fails on the strict one, with no warning until you get there 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.