Direct answer
Yes, you can add a lightweight versioning header to Marshal output — but not by extending Marshal itself. Marshal is a C-implemented binary protocol with no plugin or hook architecture, so the realistic design is a wrapper: prepend your own magic bytes and schema version to the Marshal byte stream, and strip them before calling Marshal.load. The cost is a few bytes per stream and one O(1) prefix check, and backward compatibility with existing streams is achieved by detecting the absence of your magic bytes and falling back to plain Marshal.load. No new Marshal API is required.
Why Marshal breaks on renames
Marshal embeds class and module names as literal strings in the stream. On load, Ruby resolves those names against the current constant table. If you renamed Order to Billing::Order, Marshal.load raises ArgumentError (undefined class) or TypeError (incompatible marshal format) because the constant lookup fails. The stream itself is fine — the environment changed. This is the fragility a version header can detect, though not by itself repair.
A minimal wrapper design
module VersionedMarshal
MAGIC = "VM".b
CURRENT_VERSION = 1
def self.dump(obj)
MAGIC + [CURRENT_VERSION].pack("C") + Marshal.dump(obj)
end
def self.load(bytes)
if bytes.start_with?(MAGIC)
version = bytes.getbyte(2)
raise "Unsupported schema version #{version}" if version > CURRENT_VERSION
Marshal.load(bytes[3..])
else
Marshal.load(bytes) # legacy stream, no header
end
end
end
The magic prefix (VM) lets you distinguish versioned streams from legacy ones, because a valid Marshal stream starts with the bytes \x04\x08 (major/minor version 4.8), which will never collide with your magic. That single check is what preserves compatibility with streams written before the header existed.
Trade-offs
- Stream size: a handful of bytes per stream (magic + version byte). Negligible unless you serialize millions of tiny objects individually.
- Parsing speed: one prefix comparison and one integer read before delegating to the C loader — effectively free. The expensive part remains Marshal's own deserialization.
- Compatibility: old readers cannot read new streams, so roll out the reader first, then the writer, if multiple processes share the data.
What the header does not solve
Two cautions matter more than the mechanics. First, a version number only tells you a mismatch exists; mapping Order to Billing::Order still requires explicit aliasing logic (for example, defining the old constant as an alias, or a migration pass over stored blobs). Second, Marshal.load on untrusted data is a code-execution risk regardless of any header — the header is not a security boundary. If these backups cross trust boundaries, consider JSON or a schema-bearing format instead.
Verification
Confirm the failure mode and the wrapper in irb: serialize an instance of a class, remove or rename the class constant, and call Marshal.load — you should see ArgumentError: undefined class/module. Then round-trip through VersionedMarshal with both a headered stream and a raw Marshal.dump output to confirm the fallback path works.