Skip to main content

Statements

A program is a sequence of statements. This page describes assignment, function definitions, conditionals, loops, and load, along with the rule that makes top-level names assignable once and the scoping rules for functions.

Simple statements fit on one line and can be separated by semicolons. Compound statements (if, for, while, def) own an indented block. See Lexical Elements for the indentation rules.

Expression statements​

An expression on its own line is evaluated and its value is discarded. This is how a program calls functions for their effect:

print("starting")
ui.info("deploying")

Assignment​

An assignment such as name = expression binds a name to a value. The target can also be an element of a list or dictionary, such as items[0] = 1 or settings["region"] = "us-east-1".

def demo():
count = 3
items = [1, 2, 3]
settings = {}
items[0] = 10
settings["region"] = "us-east-1"
return count, items, settings

print(demo()) # (3, [10, 2, 3], {"region": "us-east-1"})

Tuple unpacking​

A tuple or list on the left side unpacks a sequence on the right. The number of targets must equal the number of values, and targets can nest:

def demo():
first, second = 1, 2
first, second = second, first # swap
(a, b), c = (1, 2), 3
[x, y] = [4, 5]
return first, second, a, b, c, x, y

print(demo()) # (2, 1, 1, 2, 3, 4, 5)

Unpacking a sequence of the wrong length is an error (too many values to unpack), and so is unpacking a string. Starred targets such as a, *rest = items are not available; use slicing instead: first = items[0], rest = items[1:]. Chained assignment (a = b = 0) and type annotations (x: int = 0) are also syntax errors; use separate statements.

Augmented assignment​

The operators +=, -=, *=, /=, //=, %=, &=, |=, ^=, <<=, and >>= combine an operator with assignment. For a list, += extends the list in place, so every name that refers to the list sees the change. The form l = l + [x] creates a new list instead.

def demo():
n = 7
n //= 2
n += 10
names = ["a"]
alias = names
names += ["b"]
copy = names + ["c"]
return n, alias, copy

print(demo()) # (13, ["a", "b"], ["a", "b", "c"])

Top-level names are assigned once​

At the top level of a file, each name can be bound only once. A second assignment, an augmented assignment, or reusing the same loop variable in two top-level loops fails with cannot reassign global. Assigning the same name in both branches of a top-level if and else counts as two bindings:

total = 0
total = 1 # error: cannot reassign global total
if len(env) == 0:
mode = "empty"
else:
mode = "full" # error: cannot reassign global mode

Choose the value with a conditional expression, or compute it in a function and assign the result once:

mode = "empty" if len(env) == 0 else "full"

def pick_mode():
if len(env) == 0:
return "empty"
return "full"

output = pick_mode()

Changing the contents of a list or dictionary is not rebinding, so this is allowed at the top level:

state = {"count": 0}
for name in ["api", "worker"]:
state["count"] += 1
output = state["count"] # 2

Inside a function, local variables can be rebound as often as needed. Put loops that need a running total inside a function:

def total_of(numbers):
total = 0
for n in numbers:
total += n
return total

output = total_of([1, 2, 3]) # 6

Scope​

A name assigned inside a function is local to that function. A function can read names defined at the top level or in an enclosing function, but assignment never reaches outward, and there are no global or nonlocal declarations. To share changing state with an enclosing scope, mutate a list or dictionary:

def make_counter():
state = [0]
def next_value():
state[0] += 1
return state[0]
return next_value

counter = make_counter()
counter()
print(counter()) # 2

Reading a local variable before it is assigned in the same function is an error (local variable n referenced before assignment), even when the name also exists at the top level. Top-level names must be defined before the code that runs them, but a function body can refer to a top-level name that appears later in the file, provided the function is called after that name exists.

Function definitions​

A def statement, written def name(parameters): followed by an indented block, defines a function and binds it to a name. The block runs each time the function is called.

def greet(name):
return "Hello, " + name

print(greet("Atmos")) # Hello, Atmos

Parameters​

A parameter list can contain, in this order:

Required parameters
Plain names such as name. The caller must supply a value.
Optional parameters

Written as name=default. The default expression runs once, when the function is defined. A list or dictionary default is therefore shared across calls, so use None as the default and create the collection inside the function.

Variable positional parameter
*args collects extra positional arguments into a tuple.
Keyword-only parameters

Parameters after *args, or after a bare *, must be passed by keyword. They can be required or optional.

Variable keyword parameter
**kwargs collects extra keyword arguments into a dictionary. It comes last.
def deploy(name, region = "us-east-1", *replicas, strategy, dry_run = False, **labels):
return (name, region, replicas, strategy, dry_run, labels)

print(deploy("api", strategy = "rolling"))
# ("api", "us-east-1", (), "rolling", False, {})
print(deploy("api", "eu-west-1", 2, 3, strategy = "canary", team = "platform"))
# ("api", "eu-west-1", (2, 3), "canary", False, {"team": "platform"})
def connect(host, *, timeout):
return host + ":" + str(timeout)

print(connect("db", timeout = 30)) # db:30

The rules the parser and the call enforce:

  • A required positional parameter cannot follow an optional one.
  • A parameter name can appear only once, and *args and **kwargs at most once.
  • Default expressions cannot refer to earlier parameters.
  • A call that omits a required argument, passes too many positional arguments, names an unknown keyword, or gives a parameter two values is an error.

Positional-only parameters and annotations are not available.

Closures, nesting, and recursion​

Functions can be defined inside other functions and inside if blocks. A nested function keeps access to its enclosing function's variables. Recursion is allowed; a function that never reaches its base case stops with a recursion depth error after about ten thousand nested calls.

def factorial(n):
return 1 if n <= 1 else n * factorial(n - 1)

print(factorial(25)) # 15511210043330985984000000

return​

The statement return expression ends the function and gives the caller a value. A bare return, or reaching the end of the block, produces None. Return several values as a tuple and unpack them at the call site. Use return only inside a function; at the top level it is a syntax error.

def min_max(numbers):
return min(numbers), max(numbers)

low, high = min_max([3, 1, 4])
print(low, high) # 1 4

if, elif, and else​

An if statement runs the first block whose condition is true, and the optional else block runs when none is. Conditions follow the truthiness rules, so if items: tests for a non-empty list.

def classify(replicas):
if replicas > 10:
return "large"
elif replicas > 3:
return "medium"
else:
return "small"

print(classify(5)) # medium

Conditional statements are available at the top level of a file as well as in functions. There is no match statement.

for​

The statement for variable in iterable: runs the block once per element. The iterable can be a list, tuple, dictionary (which yields keys), set, or range, or the result of a method such as .items(), .elems(), or .codepoints(). Strings, bytes, and integers are not iterable directly. A tuple of variables unpacks each element.

def demo():
lines = []
for name in ["api", "worker"]:
lines.append(name.upper())
for key, value in {"region": "us-east-1"}.items():
lines.append(key + "=" + value)
for index, item in enumerate(["a", "b"]):
lines.append(str(index) + ":" + item)
for ch in "ab".elems():
lines.append(ch)
return lines

print(demo())
# ["API", "WORKER", "region=us-east-1", "0:a", "1:b", "a", "b"]

A loop cannot add to or remove from the list, dictionary, or set it is iterating. Iterate over a copy (for x in list(items):) when the loop changes the collection. A for statement has no else clause. At the top level, the loop variable becomes a global name, so two top-level loops cannot reuse the same variable; put the loops in a function or use different names.

while​

The statement while condition: repeats its block as long as the condition is true. Atmos enables while, which standard Starlark omits. Make sure the condition eventually becomes false, or use break.

def wait_for_even(start):
n = start
while n % 2 == 1:
n += 1
return n

print(wait_for_even(7)) # 8

A while statement has no else clause. Rebinding a variable in a top-level loop is subject to the assign-once rule, so loops that count or accumulate belong in a function.

break, continue, and pass​

The break statement exits the innermost loop, continue skips to its next iteration, and pass does nothing, which satisfies the grammar where a block is required. Using break or continue outside a loop is a syntax error.

def sum_even_below(limit):
total = 0
for n in range(100):
if n >= limit:
break
if n % 2 == 1:
continue
total += n
return total

print(sum_even_below(7)) # 12
def todo():
pass

load​

The statement load("path", "name") runs another file once and binds its top-level names into the current file. List one or more names, and rename with alias = "name":

# helpers.star
SETTINGS = {"region": "us-east-1"}

def double(n):
return n * 2
# main.star
load("helpers.star", "double", config = "SETTINGS")

print(double(4)) # 8
print(config["region"]) # us-east-1

Rules for load:

  • A load statement belongs at the top level of a file, not inside a function.
  • A relative path resolves against the directory of the file that contains the load. For an inline script, it resolves against the step's working directory.
  • Each file is evaluated once and cached, and a file that loads itself, directly or through another file, is an error.
  • Names that begin with an underscore stay private and cannot be loaded. Asking for a name the file does not define is an error.
  • A loaded name is bound in the importing file and follows the assign-once rule, so double = 3 after loading double is an error.
  • Every value a file defines is frozen after it loads, so the importing file can read a loaded dictionary or list but cannot modify it. See Mutability and freezing.
  • The predeclared modules, such as exec, json, and fs, are available in loaded files.

See the load reference for path resolution details.

Raising errors​

The language has no try, except, raise, or assert. Stop a program with the built-in fail(message), which reports the message and a traceback. To handle an external command that might fail, pass check=False to exec.run and inspect the result:

def require_positive(n):
if n <= 0:
fail("expected a positive number, got " + str(n))
return n

result = exec.run(["git", "--version"], check = False, output = "capture")
output = result.exit_code

Statements the language omits​

The following Python statements do not exist, and using their keywords is a syntax error: class, import, try, with, raise, del, global, nonlocal, yield, async, and await. Use dictionaries and functions instead of classes, load() instead of import, d.pop(key) instead of del, and fail() instead of raise. See Differences from Python for the complete list.

Next steps​