A practical introduction to the Starlark language

Have you ever needed a simple, thread-safe scripting language for your application—one that looks like Python but is safe and easy to embed? I’d suggest Starlark.

If you’re looking for a broader overview of the language, check out my previous post: An Overview of Starlark. Here, I’ll explain how it actually works.

An embedded language

Starlark is a simple, thread-safe language with Python-like syntax, designed to be embedded in a host application.

Let’s start with a concrete example of Starlark code:

load("dep.star", "process")
load("data.star", "data")

_cache = {}

def cached_process(arg):
  if arg in _cache: return _cache[arg]
  result = process(arg)
  _cache[arg] = result
  return result

processed_data = [cached_process(x) for x in data]

As you see, it looks pretty much like Python. As we’ll explore the language, I’ll refer back to this example to explain key concepts, including my favorite language feature, called “freezing”.

Implementations

Starlark has three production-ready implementations: one in Java, one in Go, and one in Rust. These implementations have been tested in production environments for years.

Each implementation is actively maintained and is used by multiple companies or open-source projects. Note, however, that the Java implementation is currently part of the Bazel project and doesn’t yet offer a stable public API.

If you want to try Starlark quickly without setting up your own environment, you can use the Starlark Playground that I’ve recently put online: it’s a browser-based tool that lets you write and execute Starlark code instantly: Starlark Playground. You can play with the examples that are provided.

The module system

Starlark uses the concept of a module, which typically refers to a source file or a block of code. Evaluating a module follows these steps:

  • Read and Parse: The source code is read and parsed into a syntax tree.
  • Load Dependencies: Starlark checks for load statements, ensuring dependencies are loaded (recursively and in parallel, if necessary).
  • Static Checks: Names are resolved, ensuring all variables, functions, and symbols are valid and conflict-free.
  • Evaluate Code: The code is executed, defining variables and functions.
  • Freeze Variables: All globals are made immutable, ensuring thread safety.
  • Expose Bindings: The resulting variables and functions are made available to the host application or other modules.

In the rest of the article, we’ll discuss these steps in more details.

Load dependencies

In Starlark, load statements are similar to Python’s import. They bring external symbols, e.g. functions, variables, or constants, into the current environment:

load("dep.star", "process")
load("data.star", "data")

Here, process and data are imported from their respective modules. Before execution begins, Starlark gathers all requested modules, resolves their dependencies, and loads them in parallel if possible. Each module is loaded only once and cached for efficiency.

Importantly, in contrast to Python, loading a module does not have visible side effects. This means the order of load statements in your script doesn’t matter. Code formatters are free to reorder them without changing behavior.

Static checks

Before running the code, Starlark performs static checks to validate the module. These checks include:

  • Ensuring all variable and function names are defined.
  • Preventing conflicts between global variables and symbols imported via load.
  • Disallowing reassignment of global variables, or redefinition of functions within the same module.

For example, in the code above, _cache is defined once and cannot be reassigned later. If there’s a conflict, such as defining a new variable with the same name as an imported symbol, Starlark will raise an error.

This is static, so Starlark will throw an error if an undefined variable is referenced, even if that code is actually not dynamically executed. These static checks can help users reason about the code, as they remove some potential cases of action at a distance where something is sneakily redefined later in the file.

This also eliminates the need for a global keyword from Python, since the values once assigned remain constant. However, variables can be shadowed, like in most programming languages. So it would be possible to have another data variable inside cached_process.

Code evaluation

Once the static checks are completed, Starlark evaluates the code in order. It works as you would expect, with a few notes:

  • load statements simply bring symbols into the environment. They don’t execute any code, as this was done before the evaluation step.
  • def statements assign functions to names in the environment, much like variable assignments.

This means that if you try to call cached_process before it’s defined in the module, it would result in a runtime error. The static check only ensures that cached_process is defined somewhere in the scope; the dynamic check is needed to catch the other cases.

Note also that code evaluation is deterministic and the behavior is always well defined. For example, iterating on a hash table will always yield the same values in the same order. By default, there’s no function that can access the environment, not even the clock.

Freezing

After the code is evaluated, Starlark “freezes” all global variables and functions. This means that their values become recursively immutable.

In the example, both _cache and processed_data are frozen after evaluation, as well as anything inside the processed_data list. So the list can now be safely used across multiple threads without concerns of race conditions.

The behavior of the cached_process function becomes more subtle. If the function is called with a value not in the cache, it will result in a runtime error, since the cache is frozen.

load("main.star", "cached_process")
cached_process(8) # may raise a runtime error

Without freezing, a global cache modified by one thread could lead to unpredictable behavior in another. Starlark eliminates this risk by making everything immutable after evaluation.

Exposing Bindings

Once a module is evaluated and frozen, its variables and functions become bindings that other modules or the host application can use. However, names starting with an underscore (like _cache) are private and cannot be imported elsewhere.

In our example, processed_data is exposed and can be accessed via load statements or directly by the host application:

load("example.star", "processed_data")
print(processed_data)

Conclusion

Starlark is a deterministic, thread-safe language that offers a familiar imperative style while enforcing strong guarantees against common pitfalls like race conditions or unexpected side effects. Its parallel module loading and immutability mechanisms make it well-suited for applications that use concurrency or care about determinism.

If the host application wants to call a user-defined function, it can do so safely, without having to wonder whether it mutates a global variable under the hood. For example, in Bazel, hundreds of BUILD files and their dependencies are evaluated concurrently, with Starlark ensuring each module is loaded only once.

That said, Starlark is not magic. Developers must carefully consider which functions and APIs to expose to user scripts to maintain control and security.

I’ve recently put online the website starlark-lang.org where I hope to share more resources to help users. Also if you use Starlark in your product, I’d love to hear about it. Feedback is welcome!