Building with Mojo (Part 4): Compile-Time Metaprogramming in Mojo
How aliases, parameters, and decorators push work to compile time for faster, safer Mojo programs.
This article is Part 4 of our ongoing series on the Mojo programming language. Part 1 introduced Mojo’s origins, design goals, and its promise to unify Pythonic ergonomics with systems-level performance.
Part 2 covered Mojo’s SIMD-first model in practice.
Part 3 covered converting a Python program to Mojo, and exploring how they interact.
In this article, we will discuss Mojo’s possibilities to do metaprogramming, which is possible at compile time. Let’s first explain what we mean by metaprogramming.
To follow along, use pixi to create a project called comptime, cd into it, and start a pixi shell.
All code examples used in this article can be found in the code repo here and here.
What is metaprogramming and why is it important?
Metaprogramming is about transforming or generating code at compile time. The new or changed code is then compiled and executed at run time.
One of the great characteristics of Python is that you can change code at run time—so-called run-time metaprogramming. This can do some amazing things, but it comes at a great performance cost.
The modern trend in programming languages is toward statically compiled languages, with metaprogramming done at compile time. Think about Rust macros, Swift, Zig, and Jai. Mojo, as a compiled language, fully embraces compile-time (comptime) metaprogramming, built into the compiler as a separate stage of compilation—after parsing, semantic analysis, and IR generation, but before lowering to target-specific code. To do this, the same Mojo language is used for metaprograms as for run-time programs, and it also leverages MLIR. This is because work in AI needs high-performance machine learning kernels and accelerators, and high abstraction capabilities provided by advanced metaprogramming systems. You can also write imperative compile-time logic with control flow, even compile-time recursion.
Currently, the following techniques are used in Mojo to do metaprogramming:
Defining aliases at comptime. These are constants, so they won’t change during the execution of the program.
Running arbitrary Mojo code at comptime to set parameter values with an alias.
Checking a function constraint at comptime with a
constrainedstatement.Using the decorators
@parameter forand@parameter ifto run theforandifloop they mark at comptime. The for-loop is unrolled at comptime and the if-condition is checked at comptime, so that code is only generated for the branch that evaluates to true. Use@parameterto make a function run at comptime and turn it into a parametric closure.Using
@always_inlineto force a function to be always inlined. The body of the function, with parameter values inserted, is then placed at the function call site.Functions and structs with overloading on parameters: using parameters in functions to transfer part of the computation to comptime, so that execution time is reduced.
All Python processing happens at comptime; that is, all interaction between the Python interpreter and the Mojo compiler happens at comptime. Python code is processed at comptime by the CPython interpreter.
Note that memory used by Mojo is not freed by a process at run time like a garbage collector (GC). Instead, the Mojo compiler, while analyzing how data is manipulated through code, inserts the code to free memory in the executable during comptime.
So we see that a lot of preparation and processing can be done at comptime, leading to speed gains at run time. For example: a function can be executed at comptime and keep its results for use at run time.
We’ll explore examples of these techniques in the rest of the article, but first let us clear up the difference between comptime and run time.
Compile time and run time
Python, being interpreted, has only one phase, namely run time: it is interpreted while it is running. But Mojo, as a compiled language (with mojo build), splits compilation from execution, so it has two phases:
Compile time (abbreviated to comptime) is when the compiler scans your program and generates errors or warnings. If no errors are found, an executable (containing machine code) is generated. Data that is known at comptime is said to be statically known.
Run time is when the executable runs on your machine. Often, data is not known at comptime, but only at run time—for example, when it is read in from a network or calculated with input during execution. Such data is said to be dynamically known.
Mojo code can also run at compile time, following the trend in many other modern statically compiled languages.
Everything that can be done at comptime has a performance benefit because it saves run time. This is a form of metaprogramming, which is extensively used in Mojo. Python can also do metaprogramming, but only at run time. Mojo can simulate some types of dynamic programming, but it can do a lot more as well.
Now we’ll show example code using some of these techniques.
Aliases and running functions at comptime
Executing code at comptime is one of the key aspects of Mojo’s performance. It lowers the amount of code to be executed at run time. This can be done in a flexible way: functions can be annotated with decorators such as @parameter if and for to make them run at comptime and preprocess the code to execute.
When the argument values of a function are already known at the time of writing the code (and thus also at comptime), we might as well execute the function at comptime, saving the results in the executable for use at run time. Think of doing a complex calculation or building a data structure and shaving off quite a bit of the execution time!
As we saw in Part 1, an alias can be defined like this:
alias SUM = sum(10, 20, 2) # A
fn sum(lb: Int, ub: Int, step: Int) -> Int:
var total = 0
for i in range(lb, ub, step):
total += i
return total
fn main():
print(SUM) # => 70
print(sum(10, 20, 2)) # => 70 # B#A SUM is calculated by executing the sum function at comptime.#B The same sum is calculated at run time.
An alias is a comptime constant. A variable, on the other hand, exists only at run time and has a limited lifetime (scope) in which it is known.
The right-hand side of an alias declaration is executed at comptime. The line print(SUM) compiles to the code print(70), which is displayed at run time. The result is that there is less to compute at run time, which makes the code more performant.
The function sum can be used both during comptime and run time. The alias SUM ensures that the calculation is done at comptime, making it usable at run time. The result is stored as an alias constant in the executable file. This means that when the program is run, it just takes the alias constant.
Aliases are used a lot in code, also as fields inside structs and traits. They can even be used to set a type at compile time, like this:
alias dtype = DType.float32The way DType is used here creates specialized code at comptime, optimized for the type Float32.
Not every function can be run at comptime, however. To be able to do that, the function must be:
an
fn-type function (so adeffunction cannot be run at comptime);a pure function. Such functions have no side effects; that is, they can only use the arguments passed to them and cannot change any variables or state outside of the function body.
Functions can also be conditionally executed at comptime, either by testing known or computed alias values or checking whether a certain name is defined at the command line.
Conditional execution at comptime
A function at comptime can also be called conditionally using an if/else test on values that are known at comptime. Working further on the example of the previous section, let’s replace the line alias SUM = sum(10, 20, 2) with the following code (see alias_calc_condition.mojo):
alias lb = 10
alias ub = 20
alias step = 2
alias SUM = sum(lb, ub, step) if lb < ub else 0 # A
# A Call of sum() at comptime is dependent on the values of lb and ubNow the sum function will only be called when the condition lb < ub is met. Replace the value of lb in the above code with 50, and see that SUM now becomes 0 instead of 70.
Another trick is to make the comptime execution of a function dependent on a command-line variable provided with -D. In the following version (see alias_calc_conditionD.mojo) we start the program with:
$ mojo -D calc_sum alias_calc_conditionD.mojoThe result for SUM is still 70. The functions main() and sum() remain the same, but now the alias condition is changed to:
from sys import is_defined # A
alias lb = 10
alias ub = 20
alias step = 2
alias SUM = sum(lb, ub, step) if is_defined[”calc_sum”]() else 0 # B
# A The function is_defined is imported from the sys module
# B is_defined is called here: this tests if a variable calc_sum is defined on the command lineThe line is_defined[”calc_sum”]() checks whether a command-line variable calc_sum is provided after the -D option flag. Only then is the function sum(lb, ub, step) called.
The more info a function can get at comptime, the more useful its execution at comptime becomes. Mojo introduces parameters for that purpose: they are used at comptime, in contrast to function arguments which are used at run time.
Mojo function parameters work like arguments, but they must be known at comptime. They also allow for more generic and flexible functions. Understanding parameters is one of the secrets any upcoming Mojo developer must master: they are pervasive in stdlib code and commonly used in heavy calculations. Let’s dig deeper into them.
Using parameters in functions
Another powerful tool for running functions is comptime parameterization. This technique (that originated in Zig) uses a list of parameters provided between [] in the function header. These parameters must be (or reduce to) constant values at comptime and are used inside the function’s code. Using them, the compiled code for the function can be simplified, so that run-time execution performs better. We will start with a simple parameter example.
A function with a parameter
In Listing 1, we see a greet function that prints out a message variable one time. Suppose we want to greet several persons at the same time. Using a for-loop around greet would be an obvious solution, and we can pass the number of greetings as a variable count, as we’ve done in fn greet_repeat_args. But an alternative is to pass count as a parameter between [], as we’ve done in fn greet_repeat[count: Int](msg: String) (see fn_param.mojo):
Listing 1 — A function with a parameter
fn greet(msg: String):
print(msg, end=” / “)
fn greet_repeat_args(count: Int, msg: String): # A
for _ in range(count):
greet(msg)
fn greet_repeat[count: Int](msg: String): # B
for _ in range(count):
greet(msg)
fn main():
greet(”How are you?”)
print()
greet_repeat_args(3, “Hello there!”) # C
print()
greet_repeat[3](”Hello there!”) # D
# =>
How are you? /
Hello there! / Hello there! / Hello there! /
Hello there! / Hello there! / Hello there! /#A The function greet_repeat_args has two arguments count and msg.#B The function greet_repeat has a count parameter between [].#C Arguments are passed between ().#D The parameter count’s actual value is passed between [].
The output is the same, so why bother? Everything passed to a function between [] is a compile-time parameter (like count in our example). This parameter becomes a run-time constant. The compiler makes a specific version of greet_repeat with the count value set to 3, like this:
fn greet_repeat(msg: String):
for _ in range(3):
greet(msg)which is then compiled and executed at run time. You can see that the parameter presets a value in the code. The code for fn greet_repeat_args is more complex at run time than greet_repeat, because it still must process two arguments. In this simple example, this doesn’t visibly influence performance, but for complex functions with many parameters these preset values may make a big difference in run-time execution—specifically when the parameter contains a type as value: then the run-time code can be optimized for that type.
(Note that the iteration variable in the for-loop is written as _; we don’t need to create a variable here because it is not used in the loop’s body.)
Now we know that Mojo can do computations at two different times of execution: comptime and run time, and data can be used at comptime or at run time:
Parameters (enclosed between
[]) are processed at comptime and become a static (constant) value to be used at run time.Arguments (enclosed between
()) are processed at run time and become a run-time (or dynamic) value.
A function header schematically looks like this:
fn fun_name[parameter_list](argument_list) -> return_type:The parameter list is optional as well as the return type, and the argument list can be empty.
Parametric code gets compiled at comptime, not JIT-compiled (Just-In-Time compiled) at run time. Multiple specialized versions of the code are generated, parameterized by the concrete types used during program execution.
In general, if you encounter the error cannot use a dynamic value in a type parameter, this means that you used a run-time value as a compile-time parameter.
TIP
The word parameter has a totally different meaning in Mojo than in most other programming languages, where the defined versions of a function’s arguments in its signature are called the function’s parameters. For example, in this Java function header:
public int add(int a, int b),int aandint bare called the parameters of functionadd. When the function is called, like inadd(3,5),3and5are the arguments. In contrast, in Mojo, parameters are comptime values, and arguments are run-time values.
Parameters also can have default values, for example: fn greet_repeat[count: Int = 2](msg: String). Just like variadic arguments, you can pass a variable number of parameters like this: fn add_all[*a: Int]() -> Int, which can be called as: add_all[1, 2] or add_all[1, 2, 3, 4, 5] or add_all[].
The @parameter for decorator
By adding a @parameter decorator, we can significantly improve performance by evaluating the for-loop entirely at compile time. In the function greet_repeat in the previous example, the range was known at comptime through the parameter, but the for-loop itself still had to be executed at run time, like this:
fn greet_repeat(msg: String):
for _ in range(3):
greet(msg)Mojo can do better: by prefixing the loop with the @parameter decorator like this:
fn greet_repeat(msg: String):
@parameter # A
for i in range(3):
greet(msg)
# A The @parameter for decorator unrolls the for loopNow, the loop will be unrolled. It will be changed to a sequence of statements at comptime as:
fn greet_repeat(msg: String):
greet(msg)
greet(msg)
greet(msg)This might not seem spectacular here, but when used in complex and nested for-loops, it can have massive implications for run-time speed, like in this code snippet:
@parameter
for i in range(NUM_BODIES):
for j in range(NUM_BODIES - i - 1):
var body_i = bodies[i]
var body_j = bodies[j + i + 1]A downside of unrolling loops is that the program grows in size before it is compiled, resulting in a slightly increased compilation time and executable size.
Checking constraints to which functions must comply to run is important to make your code robust. This can be done at comptime with constrained testing, or at run time using assert testing. Because this article is targeted to code executed at comptime, we won’t talk about assert testing in detail here, but there is an example of its use in the code example for @parameter if.
Constraints checking in functions
Often, we write programs in which we unconsciously suppose that certain values or arguments comply with certain conditions. In certain edge cases, these conditions might not be met, crashing our program while running, which could get us caught in a long debugging session. Now that we know about parameters, the same situation can happen there: a program can crash with a compiler error during comptime. A compiler error might not be so easy to track down, and debugging metaprogramming at comptime is not nearly as nice as normal run-time debugging. We as developers must arm ourselves against these situations, both during run time and during comptime. Luckily, Mojo has our back in this.
Checking constraints during comptime
A program that crashes because of an incompatible parameter during comptime (while doing metaprogramming) is annoying. When a function has parameters, it would be nice if we could test these parameters on certain conditions. That’s where constraints come in: they are like custom comptime checks you as a developer can add to enhance the robustness of comptime execution.
Suppose we want to make sure that our machine has an NVIDIA GPU at our disposal, running on an Apple M2 or M3 processor. This can be done as shown in Listing 2 (see constrained_checking.mojo):
Listing 2 — Using
constrainedto test comptime conditions
from sys.info import is_apple_m2, is_apple_m3, has_nvidia_gpu_accelerator
def main():
machine_checks[]()
def machine_checks[]():
constrained[has_nvidia_gpu_accelerator(), “No NVIDIA GPU present”]()
constrained[is_apple_m2() | is_apple_m3(), “Not an Apple M2 or M3 CPU”]()
# => note: constraint failed: Not an Apple M2 or M3 CPURunning on a machine which has no Apple M2 or M3 processor, the constraint failed on this condition. However, we got no warning about the GPU, so this computer has an NVIDIA GPU.
Note that constrained has the condition as its first parameter, and the message displayed in case the condition is false as its second parameter.
Other decorators for functions
Decorators are a powerful and elegant feature in many programming languages (Python; annotations in Java; modifiers in C#; and so on) that allow you to modify the behavior of a function, method, struct, or class without changing its source code. Decorators are another form of metaprogramming, since a part of the program (the decorator) tries to modify another part of the program (for example, the function or struct) at comptime.
Decorators are no stranger to us: we encountered @register-passable in previous articles. In Mojo, decorators are higher-order functions, prefixed with @ and placed in front of the function, struct, or code they influence. At comptime, the higher-order function behind the decorator name is called to modify or extend the code they decorate.
In this section, we’ll specifically focus on decorators used for code and functions. In the next section, we’ll discuss decorators for structs.
@always_inline
Inlining a function means taking its code body and replacing the function call with the function body. This improves run-time performance by avoiding function call overhead; it eliminates jumping to a new point in code. But if the function is large and called many times, we have a lot of duplicated code, increasing the program’s binary size.
Normally, the compiler will do inlining automatically where it improves performance. But you can force this behavior with @always_inline: this decorator forces the compiler to always inline the decorated function, directly into the body of the calling function.
In the following example we combine @always_inline with @parameter for (see always_inline.mojo):
@always_inline # A
fn print_and_increment(mut x: Int):
print(x)
x += 1
fn main():
var i = 0
@parameter # B
for j in range(0, 3):
print_and_increment(i)
# =>
# 0
# 1
# 2
# A @always_inline replaces the function call by the function’s code
# B @parameter for unrolls the for loopApplying @always_inline removes the code before main(), so that it is transformed like this:
fn main():
var i = 0
@parameter
for j in range(0, 3):
print(i)
i += 1After applying @parameter for, the code is reduced to:
fn main():
var i = 0
print(i) # => 0
i += 1
print(i) # => 1
i += 1
print(i) # => 2
i += 1The program is faster by not checking if j < 3 on each iteration of the range and by not having to jump into print_and_increment. It also becomes bigger, but this is a choice you make.
@always_inline is pervasively used in high-performance Mojo code.
There is a variant @always_inline(”nodebug”) which speeds up a bit more by removing the debugging information of the function, so you won’t be able to debug it anymore. Apply this variant to low-level functions which call MLIR or inline assembly code, which you probably won’t or can’t debug.
@parameter if
The decorator @parameter if will make an if statement run at compile time. In our example, it is used to define an alias debug_mode. Only when debug_mode is True will the assertion code be included and run in the final executable (see parameter_if.mojo):
Listing 3 — Using
@parameter ifto create a debug mode
from testing import assert_true
alias debug_mode = True # A
fn example():
@parameter
if debug_mode: # B
print(”debug”)
fn main() raises:
example() # => debug
@parameter
if debug_mode: # C
_ = assert_true(1 == 2, “assertion failed”)
# => AssertionError: assertion failed
# A Define debug_mode with an alias
# B Test @parameter if
# C The assert_true will only be included if debug_mode is TrueOnly when the condition of the if is True will the code of that branch be included in the compiled binary. The if statement will never run during run time.
Even better, you could also use the if_defined[] technique to set the alias debug_mode to True only if the program was started with a command-line option debug_mode, like this:
$ mojo -D debug_mode parameter_if_cmd.mojoOur alias is then defined with:
from sys import is_defined
alias debug_mode = True if is_defined[”debug_mode”]() else FalseThat way, you don’t have to change the program code, only a command-line option is needed to change the mode to debug. Start the program with:
$ mojo parameter_if_cmd.mojo(or ./parameter_if_cmd) and the assertion code will not be included or executed.
We saw how to use @parameter in combination with if and for to run the code it precedes at comptime. We can also use this decorator to make a function run at comptime.
Running a function at comptime with @parameter
In the following example, the add function, which takes two integer parameters a and b and returns an integer, is executed at comptime because it is decorated with @parameter (see parameter_decorator.mojo):
fn add_print[a: Int, b: Int]():
@parameter # A
fn add[a: Int, b: Int]() -> Int:
return a + b
var x = add[a, b]() # B
print(x)
fn main():
add_print[5, 10]() # => 15
# A The add function is annotated with @parameter
# B The add function is called at comptime and its result is printedThis translates at compile time to:
fn add_print():
var x = 15
print(x)
fn main():
add_print()The add calculation ran at compile time, so you pay no run-time price for anything inside the function.
Closures in Mojo can also handle parameters. Let’s first show what a closure is, and then discuss how they work with parameters.
Closures
A function takes values in as arguments when it is called and processes these; this is how a function gets its data. But there is another way that a function can get data from the surrounding scope, used with nested functions. This is often called capturing data, and the function is called a closure: it copies in (closes over) the variables to get its data. The captured variables are owned by the closure. The variables that are captured must be initialized before the definition of the function itself. Capturing means that the closure knows the values of any variables in context.
Listing 4 is an example of a closure inner(), which captures the value of the num variable (see closure.mojo):
Listing 4 — A closure capturing variables
fn outer(f: fn () escaping -> Int): # A
print(f()) # B
fn call_it():
var num = 5 # C
fn inner() -> Int: # D
return num # E
outer(inner)
fn main():
call_it() # => 5
# A The function type of inner is fn () escaping -> Int
# B The closure inner is called here
# C The variable num will be captured
# D The function inner is a closure that captures the context variable num
# E num is known inside innerThe value of num is copied to the inner function. This all happens at run time, and such a closure can be passed as an argument to other functions, like in our example. The inner closure is passed to the outer function. inner() is called a run-time closure, and such closures have as type: fn() escaping → Type. The escaping keyword indicates that this closure can “escape” the scope in which it was defined, meaning it can be returned from the function and used elsewhere.
An error message you can get when you call a function but forget the argument list (), is the following: error: cannot emit closure for method ‘funcname’, or: function pointer was formed but not called, did you forget ‘()’s? Without (), Mojo thinks you want to use the function as a closure. Simply add () to the back of funcname to solve this.
Compile-time closures do exist also, as we’ll discuss now. To declare them we need the decorator @parameter.
Defining parametric closures with @parameter
Closures are local functions that can capture variables from their context, as we saw in the previous section. Now what if you would like to pass the inner function to the outer function, not as an argument, but as a parameter?
Then this code:
var num = 5
fn inner() -> Int:
return num
outer[inner]()results in an
error: cannot use a dynamic value in call parameterWhy is this? The inner function is dynamic because it is still executed at run time and thus cannot be passed as a parameter. Decorating the inner function with @parameter makes it static, which means it is executed at comptime, so it can be passed as a parameter. Of course, we also need to change the function header of outer, adding [] and not forgetting the () in the definition as well as the call. The keyword escaping now also needs to change to capturing, which indicates that a function can access and use variables from its enclosing scope.
The complete example with inner used as a parameter goes as follows (see parametric_closure.mojo):
Listing 5 — Defining a parametric closure
fn outer[f: fn () capturing -> Int](): # C
print(f())
fn call_it():
var num = 5
@parameter # A
fn inner() -> Int:
return num
outer[inner]() # B
fn main():
call_it() # => 5
# A Decorator @parameter is needed to pass inner as a parameter
# B Passing inner as a parameter. Don’t forget the ()
# C The parameter declaration needs to declare the function parameter as capturingCompare this code with the previous listing. It is a good exercise to change the code step by step, going from the previous code to the parametric code.
Closures like inner() are called parametric closures or compile-time closures. They capture values defined before their declaration: inner() captures the value of num. Their function type is always of the form: fn() capturing → Type.
To make sure that the captured value(s) are copied into the closure (instead of passing a reference to the closure) precede @parameter with the decorator @__copy_capture, like this:
@__copy_capture(num)
@parameterParametric closures are the backbone of stdlib functions like vectorize and parallelize, which are heavily used in calculations.
Structs with parameters
Like functions, generic structs can be defined with parameters declared between []. Many structs from the stdlib take a type as parameter. This is processed at comptime to generate optimal code for that type. The SIMD vector type, which was discussed in a previous article, is defined with two parameters as: struct SIMD[type: DType, size: Int].
Here is an example:
var sd = SIMD[DType.int16, 4](1, 2, 3, 4)You can also enhance your own structs with parameters, as done in the following listing (see planets_param.mojo):
Listing 6 — Using parameters in struct definition
@fieldwise_init
struct Planet[earth_like: Bool]: # A
var mass: Int
var name: String
fn describe_planet(self):
if earth_like: # B
print(”Attention: Earth-like planet ahead!”)
print(”Name:”, self.name, “Mass:”, self.mass)
fn main():
var mars = Planet[True](80, “Mars”) # C
mars.describe_planet() # =>
# Attention: Earth-like planet ahead!
# Name: Mars Mass: 80
# A Definition of parameter earth_like
# B Parameters can be used in method’s code
# C A value is given to the parameter when the struct is madeA parameter earth_like, which must be known at comptime, is defined for the struct Planet. Now when defining a new instance of Planet, you must give the parameter a value accordingly. This value can then be used in code, as we did here in the method describe_planet.
A general struct header schematically looks like this: struct[parameters](arguments).
Another stdlib type that uses parameters is List, defined in the module collections. List is like a workhorse in Mojo, used for many common tasks.
Lists in Mojo are homogeneous, containing only one type of items. This type can be specified at comptime as a parameter like List[Float64]. That’s why such a type is generic: you can define it for all types, like a List can contain Int16 values, or Float64, or String, or struct Person instances. The Dict type for a dictionary also takes key and value types as parameters. List and Dict are statically typed in Mojo; that is, the types of their data must be known at comptime.
New metaprogramming techniques will surely be added in the coming Mojo versions.
© 2025 Ivo Balbaert. All rights reserved.






Thanks!