Mojo Language C Interop: What Actually Works and What Bites You
ML engineers calling BLAS, custom CUDA wrappers, or inference engines written in C — they already live at the boundary between two worlds. Mojo language C interop is the mechanism that makes that boundary survivable without dropping back to ctypes or writing glue C. As of v1.0b1 (May 2026), three mechanisms cover most cases: external_call, DLHandle, and the new abi("C") function effect. Each has a distinct failure mode. This piece covers all three.
TL;DR: Quick Takeaways
external_callcompiles to a direct LLVM call instruction — zero wrapper overhead, but no native errno access (Issue #2532, open as of v1.0).- The
mojo runvsmojo buildlinker discrepancy was a real JIT bug (Issue #6155), fixed in v1.0b1 — update before debugging your flags. abi("C")is mandatory for struct arguments and callbacks crossing the C boundary; skipping it is a silent segfault in older builds.UnsafePointerpassed inside a conditional branch can be freed before the C function executes — Issue #3226 is the nastiest FFI trap in the language.
external_call — The Blunt Instrument
external_call is not a wrapper. It maps directly to an MLIR/LLVM intrinsic — at compile time, it emits a call instruction into the ABI, exactly like calling a C function from C. The function name is a compile-time string parameter, not a runtime value, because MLIR resolves the symbol during lowering, not at execution. That distinction matters: you cannot compute the function name dynamically. If you need that, you want DLHandle.
The syntax is parametric:
# Requires: Mojo v1.0b1+
from sys.ffi import external_call
from sys import c_char
fn get_env_var(name: String) -> String:
# external_call resolves from libc / process-loaded symbols
var result = external_call["getenv", UnsafePointer[c_char]](
name.unsafe_ptr()
)
if result == UnsafePointer[c_char]():
return ""
return String(result)
The getenv call resolves from libc because libc is always loaded into the process. For any library outside the default process scope, you have to tell the linker explicitly — and this is where early Mojo hurt people badly.
The mojo run vs mojo build Linker Bug
Before v1.0b1, passing -Xlinker flags to mojo run silently dropped them in the JIT. The same code compiled with mojo build worked fine. People spent hours convinced their linker paths were wrong. They weren’t — the JIT simply wasn’t forwarding the flags. Issue #6155, fixed in v1.0b1.
# Correct invocation — v1.0b1+ passes these to the JIT linker
mojo run -Xlinker -L/usr/local/lib -Xlinker -lfoo main.mojo
The -Xlinker prefix is required for each flag — you cannot collapse them. If you’re on an older build and hitting “cannot resolve symbol” only under mojo run, that’s the bug. Upgrade first, then debug your paths.
The mojo run vs mojo build discrepancy was one of the most consistently confusing issues in early Mojo FFI work. It looked like user error — wrong rpath, missing ldconfig cache — but it was a runtime linker gap in the JIT.
errno After a Failed Call
Mojo gives you no native errno access after external_call. The C function sets errno in thread-local storage, and Mojo doesn’t expose a hook to read it post-call. Issue #2532 has been open since May 2024.
# Workaround: read errno via __errno_location() on Linux
# Not elegant. Works.
fn get_errno() -> Int32:
var loc = external_call["__errno_location", UnsafePointer[Int32]]()
return loc[]
fn open_file(path: String) -> Int32:
var fd = external_call["open", Int32](path.unsafe_ptr(), 0)
if fd == -1:
var err = get_errno()
return -err # Return negative errno by convention
return fd
Two calls instead of one, and it’s Linux-specific (__error() on macOS). Ugly, but it’s the only option until Issue #2532 gets closed.
Bottom line: external_call is the right tool for libc and system calls — fast, zero overhead, compile-time resolved — but errno handling requires a manual workaround that shouldn’t exist in a v1.0 language.
DLHandle — Dynamic Loading for Real Libraries
When you need to load a custom .so at runtime — BLAS, a custom inference engine backend, a plugin — DLHandle from the sys.ffi module is the right path. Under the hood it wraps dlopen/dlsym on Linux and macOS. The Mojo mojo load .so shared library dynamically story is entirely DLHandle.
Worth noting: DLHandle was largely undocumented until recently. Early adopters were reverse-engineering its API from changelog entries and community experiments. The current pattern, stable as of v1.0b1, looks like this:
# Requires: Mojo v1.0b1+ (abi("C") enforcement on get_function added here)
from sys.ffi import DLHandle
fn load_and_call_sqrt() -> Float64:
var handle = DLHandle("/usr/lib/libm.so.6")
# get_function NOW requires abi("C") on the type parameter
# Omitting it is a compile error in v1.0b1+
var sqrt_fn = handle.get_function[
def(Float64) abi("C") -> Float64
]("sqrt")
return sqrt_fn(2.0)
The abi("C") enforcement on get_function[] is new in v1.0b1 and intentional. Previously, calling get_function without specifying the calling convention was legal but silently incorrect for functions that pass structs — the compiler might use Mojo’s internal ABI instead of the platform C ABI. Now it’s a compile error. That’s the correct direction.
Why Mojo Was Created to Solve Python Limits Mojo exists because Python performance limitations have become a structural bottleneck in modern AI and machine learning workflows. Within this Mojo Deep Dive: Python Limits are examined...
Wrapping a C Library in a Mojo Struct
For anything you’ll call more than once, the pattern is to wrap the DLHandle in a struct and load function pointers at init time. This is how you’d front a BLAS library or a mojo inference engine C++ backend:
from sys.ffi import DLHandle
struct BLASWrapper:
var _handle: DLHandle
var _dgemm: fn(Int32, Int32, Float64, UnsafePointer[Float64],
Int32, UnsafePointer[Float64], Int32, Float64,
UnsafePointer[Float64], Int32) abi("C") -> None
fn __init__(inout self, path: String):
self._handle = DLHandle(path)
self._dgemm = self._handle.get_function[
fn(Int32, Int32, Float64, UnsafePointer[Float64],
Int32, UnsafePointer[Float64], Int32, Float64,
UnsafePointer[Float64], Int32) abi("C") -> None
]("cblas_dgemm")
fn dgemm(inout self, m: Int32, n: Int32, ...):
# safe call through typed pointer
...
One handle, one load call at construction, typed pointers for every exported function. The compiler knows the ABI at each call site. For Mojo system design patterns around C library wrappers, the struct approach is the only one that scales.
RTLD Flags
DLHandle accepts RTLD flags as a second argument. RTLD.LOCAL (default) keeps symbols private to the handle. RTLD.GLOBAL pushes them into the process-wide symbol table. That matters when you load two .so files that depend on each other — load the dependency with RTLD.GLOBAL first, then load the dependent library. Get this wrong and you’ll see “symbol not found” errors that look like broken builds but are just load-order issues.
Bottom line: DLHandle is the correct mechanism for any external .so not already in the process. The abi("C") enforcement added in v1.0b1 closes a real silent-bug class that earlier versions had.
abi(“C”) — The Calling Convention Primitive
The abi("C") function effect, introduced in v1.0b1, is the explicit bridge between Mojo’s internal function calling convention and the platform C ABI. Before it existed, passing structs to or from C functions was undefined behavior territory — Mojo might use its own layout and calling convention, which diverges from System V x86-64 ABI struct passing rules for structs larger than two machine words.
It has two distinct use cases and they’re easy to conflate.
Exporting Mojo Functions as C Callbacks
When C code needs to call back into Mojo — qsort comparators, libev callbacks, plugin entry points — the Mojo function must advertise the C calling convention:
# Mojo function callable from C — v1.0b1+
# abi("C") forces System V x86-64 / ARM64 AAPCS calling convention
fn compare_ints(a: Int32, b: Int32) abi("C") -> Int32:
if a < b: return -1 if a > b: return 1
return 0
# Pass to qsort via external_call
fn sort_array(arr: UnsafePointer[Int32], n: Int32):
external_call["qsort", NoneType](
arr, n, sizeof[Int32](), compare_ints
)
As of v1.0b1, stateless closures auto-lift to top-level functions when passed as FFI callbacks with abi("C"). Stateful closures don’t — they can’t, because a C function pointer has no closure slot.
C++ Interop: the Hard Limit
abi("C") covers C calling conventions only. Mojo calling C++ from Mojo — with name mangling, vtables, and exceptions — is not supported. For C++ libraries, you need an extern "C" wrapper on the C++ side that strips the C++ ABI surface. Then Mojo calls the wrapper with abi("C"). This isn’t a Mojo limitation to be defensive about — it’s the same constraint Rust and Zig have. C++ ABI is per-compiler, per-version, and unstable by design.
Bottom line: Always use abi("C") whenever a function crosses the Mojo/C boundary in either direction. Struct arguments especially — the silent mismatches before v1.0b1 were hard to diagnose and easy to avoid.
UnsafePointer and the Memory Boundary
Passing memory across the Mojo/C boundary means stepping outside Mojo’s ownership model. The compiler’s lifetime guarantees stop at the ABI edge. UnsafePointer is the tool for this, and it has real traps. The mojo UnsafePointer C function usage pattern looks simple until it isn’t.
Type Aliases: Mapping C Types to Mojo
The sys.ffi module provides C-compatible type aliases. These are not cosmetic — platform ABI correctness depends on using the right width:
from sys.ffi import c_int, c_char, c_size_t, c_long
# c_int = Int32 (always 32-bit, all platforms)
# c_char = Int8 (always 8-bit, all platforms)
# c_size_t = UInt (pointer width: 64-bit on 64-bit OS)
# c_long = Int64 on Linux/macOS, Int32 on Windows ← footgun
# Example: calling strlen
fn mojo_strlen(s: String) -> Int:
return int(
external_call["strlen", c_size_t](s.unsafe_ptr())
)
The c_long difference is the most common portability bug. Linux and macOS define long as 64-bit on 64-bit systems. Windows defines it as 32-bit regardless of pointer width. If you’re targeting multiple platforms and using c_long in a struct layout, test Windows explicitly — the struct size will differ.
The Lifetime Trap: When ASAP Destruction Kills Your FFI Call
Mojo uses ASAP (as soon as possible) destruction — values are freed at the end of their last use, not at the end of the scope. Inside a conditional branch, the compiler can determine that a value’s last use is before the branch completes, and destroy it there. If you’ve passed an UnsafePointer into that value to a C function, the C function may execute against freed memory.
# Issue #3226 — ASAP destruction inside conditional branch
# BAD: compiler may free 'data' before C function executes
fn bad_ffi_call(condition: Bool):
var data = List[Float32](1.0, 2.0, 3.0)
if condition:
external_call["process_floats", NoneType](
data.unsafe_ptr(), len(data)
)
# data may be destroyed BEFORE the branch body runs
# GOOD: explicit discard keeps 'data' alive past the call
fn good_ffi_call(condition: Bool):
var data = List[Float32](1.0, 2.0, 3.0)
if condition:
external_call["process_floats", NoneType](
data.unsafe_ptr(), len(data)
)
_ = data^ # explicit consume after branch — keeps it alive
This is Issue #3226, reported in v2024.7. The symptoms are random segfaults that appear and disappear depending on optimization level — exactly the class of bug that wastes entire debugging sessions. The fix is mechanical: consume the value explicitly after the branch with _ = val^. Mojo’s ownership model docs cover the underlying mechanics in more depth.
Mojo Concurrency and Parallelism Explained Mojo concurrency and parallelism explained is not just about running multiple tasks at once — it is about understanding how the runtime schedules work, how memory is shared, and how...
Void Pointers and OpaquePointer
C functions expecting void* need OpaquePointer on the Mojo side. You can’t pass a typed UnsafePointer[T] directly where void* is expected — the type parameter is part of the Mojo type and doesn’t strip automatically. The cast is explicit:
from memory import OpaquePointer
fn call_memset(ptr: UnsafePointer[UInt8], value: Int32, n: Int):
# Cast to OpaquePointer for void* parameter
var opaque = ptr.bitcast[OpaquePointer]()
external_call["memset", NoneType](opaque, value, n)
Bottom line: UnsafePointer FFI is correct-by-default for simple cases, but the ASAP destruction bug inside conditionals is a real footgun. If your C function gets garbage data intermittently, check for Issue #3226 before blaming the C side.
Static Linking and Exporting Mojo to C
Interop runs both directions. If your C++ inference engine needs to call a custom Mojo kernel, you need to export Mojo as a callable library. The @export decorator marks a Mojo function for C export — it suppresses name mangling and places the symbol in the C-visible namespace. As of v1.0b1, this feature exists and works for basic cases, but Modular has explicitly flagged the API as not fully stabilized. Use it, but don’t treat the decorator signature as frozen.
# mojo static linking external C library — export direction
# @export marks the symbol for C ABI visibility
@export
fn mojo_matmul(
a: UnsafePointer[Float32],
b: UnsafePointer[Float32],
out: UnsafePointer[Float32],
m: Int32, n: Int32, k: Int32
) abi("C") -> None:
# ... custom kernel logic
pass
For compiling Mojo into a shared library, the community mojoc script approach (see the ihnorton/mojo-ffi community repo) provides a working path. The compiled output is a standard .so with C-visible symbols that any C/C++ caller can link against. The use case is real: Mojo kernels are already faster than equivalent Python/NumPy code for certain dense linear algebra operations, and wrapping them as a drop-in library is the deployment pattern for production.
Bottom line: Export works, but treat the API as beta. The direction is right — Mojo as a kernel authoring language with C-compatible export is the correct architecture for mixed codebases.
When to Use Which Mechanism
The three mechanisms aren’t interchangeable. Each has a specific role, and picking the wrong one doesn’t always fail loudly — sometimes it just runs slower or breaks on a different platform.
| Mechanism | Use Case | Key Constraint |
|---|---|---|
external_call |
libc, system calls, symbols already in process | No runtime dlopen, no errno |
DLHandle |
Custom .so, BLAS, inference engines, plugins | Runtime path must exist; RTLD flags matter |
abi("C") |
Any function crossing C ABI — structs, callbacks | C only — C++ needs extern “C” wrapper first |
@export |
Mojo kernels called from C/C++ | API not fully stabilized as of v1.0b1 |
On the Python ctypes vs Mojo FFI question: ctypes is interpreted, dynamic, and boxes/unboxes values at each call boundary. external_call compiles to a single CALL instruction — the same output you’d get calling the C function from C. In a tight loop calling a BLAS routine, that per-call overhead difference is measurable. For ML inference pipelines where the FFI boundary is hit millions of times per second, this is the performance argument for migrating away from ctypes.
On Mojo vs Zig C interop: Zig’s C interop is more mature — it can import C headers directly and use C types without manual mapping. Mojo’s abi("C") closes most of the gap for runtime calling patterns, but Zig still wins on the static analysis side. For a pure-Mojo project, that’s not the bottleneck. For a polyglot codebase with lots of C headers to bind, it’s worth acknowledging.
Bottom line: The mojo language C interop story in v1.0b1 is usable for production FFI work. The rough edges — errno, ASAP destruction, stabilizing @export — are known, tracked, and not fatal.
Mojo vs Other FFI Approaches
Mojo isnt the only way to call C — but different languages optimize for very different trade-offs. In practice, most FFI designs fall along three axes: runtime overhead, safety guarantees, and how much of the C boundary is handled automatically vs manually. Mojo sits on the manual but predictable end of that spectrum — minimal abstraction, explicit ABI control, and near-zero overhead at the call boundary. The comparisons below use those same axes to show where Mojo is stronger, and where other approaches still have an edge.
Mojo vs Python ctypes
Python ctypes solves the same problem — calling C from a higher-level language — but the execution model is fundamentally different. ctypes is dynamic and interpreted: every call crosses the CPython runtime, performs type conversions, and boxes/unboxes values. That overhead is negligible for occasional calls, but it becomes measurable in hot paths — exactly where ML pipelines tend to sit.
Mojos external_call, by contrast, lowers directly to a native call instruction at compile time. No interpreter, no dynamic dispatch, no Python object allocation at the boundary. The result is predictable, C-level performance per call. The trade-off is that Mojo gives you less runtime flexibility — you must define types explicitly, respect ABI boundaries (abi("C")), and manage memory with UnsafePointer when crossing into C.
In short: ctypes is flexible and easy to prototype with; Mojo FFI is strict but built for throughput. For one-off integrations, ctypes is fine. For tight loops and high-frequency calls into C kernels, Mojos approach is the one that scales.
Mojo vs Zig
Zig takes a different approach to C interop: it treats C as a first-class input language. You can import C headers directly, reuse C types without manual mapping, and let the compiler handle ABI correctness at compile time. For large codebases with extensive C APIs, this eliminates an entire class of binding boilerplate.
This makes Zig especially strong in large C ecosystems where headers already define most of the interface surface.
Mojos model is more explicit. There is no header import layer — you define function signatures manually and enforce correctness via abi("C"). This makes the boundary visible and predictable, but also more verbose. Where Zig shines is static analysis and compile-time guarantees across large C surfaces. Where Mojo holds its ground is in runtime integration patterns — especially when the goal is to call a small set of high-performance kernels from a higher-level system.
Mojo Internals: Why It Runs Fast Mojo is often introduced as a language that combines the usability of Python with the performance of C++. However, for developers moving from interpreted languages, the reason behind its...
In practice: Zig is stronger for broad C integration with many headers. Mojo is sufficient — and often simpler — when you control the boundary and only need a focused FFI layer.
Mojo vs Rust FFI
Rusts FFI model is built around safety boundaries. The language enforces that all calls into C happen inside unsafe blocks, making the cost of crossing the boundary explicit. Rust also provides mature tooling (bindgen, cbindgen) to generate bindings automatically from C headers, which significantly reduces manual work in large integrations.
This also enables large-scale tooling around FFI safety, making Rust the most controlled environment for complex C integrations.
Mojo takes a more lightweight approach. There is no explicit unsafe keyword gating FFI calls — instead, the responsibility is carried by constructs like UnsafePointer and the ABI declaration (abi("C")). This results in less ceremony at the call site, but also fewer compile-time guarantees. Issues like the ASAP destruction bug (Issue #3226) highlight that lifetime safety at the FFI boundary is still evolving.
Performance-wise, both Rust and Mojo compile down to native calls with zero overhead at the boundary. The real difference is ergonomics and safety: Rust prioritizes correctness and tooling for large, complex integrations; Mojo prioritizes directness and minimal overhead for performance-critical paths.
In short: Rust FFI is safer and more mature; Mojo FFI is leaner and faster to write for small, well-understood boundaries.
FAQ
Can Mojo call C++ functions directly?
No. abi("C") handles C calling conventions only — it knows nothing about C++ name mangling, vtable layouts, or exception unwinding. For C++ libraries, you expose the API you need behind an extern "C" wrapper on the C++ side, then call that wrapper from Mojo using external_call or DLHandle with abi("C"). Any C++ object lifecycle (constructors, destructors, RAII) has to be managed inside the wrapper. True C++ interop at the vtable level is not supported as of v1.0b1.
Why does external_call work with mojo build but fail with mojo run?
This was Issue #6155 — a JIT bug where -Xlinker flags were silently ignored during mojo run, so the runtime couldn’t resolve symbols from non-default libraries. mojo build forwarded the flags correctly, which made the bug look like a user error. The fix shipped in v1.0b1. If you’re seeing “cannot resolve symbol” errors only under mojo run, update your Mojo installation before spending time on linker diagnostics.
How do I read errno after a failed external_call?
Mojo doesn’t expose errno natively after external_call — Issue #2532 has been open since May 2024. The workaround is a second external_call to __errno_location() on Linux (or __error() on macOS), which returns a pointer to the thread-local errno variable. Dereference the pointer to read the value. It adds a call but it works correctly in all cases. Watch Issue #2532 for an eventual first-class solution.
Is Mojo FFI actually zero-overhead compared to Python ctypes?
For compiled code, yes. external_call lowers to a direct CALL instruction — no interpreter dispatch, no type boxing, no Python object allocation at the call site. The overhead is identical to calling the same C function from C. Python ctypes, by contrast, must interpret the argument types at each call, allocate Python objects for return values, and route through the CPython runtime. For one-shot calls the difference is noise. For ML pipeline hot paths hitting a C kernel at high frequency, the difference is not noise — it’s the whole point of using Mojo.
What is the difference between external_call and DLHandle?
external_call is a compile-time intrinsic that resolves a symbol already present in the process — typically libc or a preloaded library. The symbol name is baked in at compile time and resolved by the static linker or the runtime loader at startup. DLHandle is a runtime mechanism: it calls dlopen to load a .so by path, then dlsym to look up a symbol and return a typed function pointer. Use external_call for system functions and libraries you always expect loaded. Use DLHandle when the library path is determined at runtime, or when you’re loading plugins, BLAS variants, or hardware-specific backends that may not exist on all systems.
What happens if I pass an UnsafePointer to a C function inside a conditional branch?
You may hit Issue #3226 — Mojo’s ASAP destruction can free the backing memory before the branch body executes, leaving the C function with a dangling pointer. The symptoms are intermittent segfaults that vary with optimization level, which makes them hard to reproduce consistently. The fix is to keep an explicit reference alive past the branch using _ = val^ after the call site. This forces the compiler to keep the value alive until the explicit consume, which is after the C function has returned. Consider this a required pattern for any FFI call inside a conditional until the issue is closed.
Analysis and code verified against Mojo v1.0b1 release artifacts. FFI-adjacent pitfalls in the Mojo performance space are a separate topic — they interact with but are distinct from the ABI boundary issues covered here.
Written by:
Related Articles