Coming from Python

You know Python: dynamic types, exceptions, for loops, classes. March keeps the readability but moves the safety checks to compile time instead of runtime.


Functions

-- Python: def add(x, y): return x + y
fn add(x, y) do x + y end

No return keyword — the last expression is the result. No colon. No indentation rules — do...end delimits the body.

Type annotations are optional (the compiler infers them) but good for documentation:

fn add(x : Int, y : Int) : Int do x + y end

Private functions use pfn — enforced by the compiler, not just a _ naming convention:

pfn helper(s : String) : String do String.to_uppercase(s) end

No None — use Option

There’s no None that silently infects your program. A value that might be absent has type Option(a):

fn find_user(id : Int) : Option(User) do
  -- returns Some(user) or None
end

match find_user(42) do
  None       -> println("not found")
  Some(user) -> println(user.name)
end

The compiler forces you to handle both cases. No AttributeError: 'NoneType' object has no attribute 'name' surprises.

Option.unwrap_or is the safe equivalent of x or default:

Option.unwrap_or(find_user(42), default_user)

No exceptions — use Result

Instead of try/except, functions that can fail return Result(ok, err):

fn parse_int(s : String) : Result(Int, String) do
  String.to_int(s)
end

Chain fallible operations with let? — it propagates the error up and returns early, like a try block that’s in the type:

fn run(input : String) : Result(Int, String) do
  let? n    = parse_int(input)
  let? user = fetch_user(n)
  Ok(user.score)
end

If parse_int returns Err("not a valid integer"), run immediately returns that error — fetch_user never runs. No exception handler needed.


No classes — modules + record types

# Python
class Counter:
    def __init__(self): self.count = 0
    def increment(self): self.count += 1
    def get(self): return self.count

March keeps data and functions separate. Records hold data; functions in the same module operate on them:

type Counter = { count : Int }

fn new() : Counter do { count: 0 } end
fn increment(c : Counter) : Counter do { c with count: c.count + 1 } end
fn get(c : Counter) : Int do c.count end

{ c with count: ... } creates a new record with one field changed — values are immutable by default. For mutable shared state, use actor or Vault.


No for loops — higher-order functions

# Python
doubled = [x * 2 for x in nums if x > 0]
total   = sum(nums)
first   = next((x for x in nums if x > 10), None)
let doubled = nums |> List.filter(fn x -> x > 0) |> List.map(fn x -> x * 2)
let total   = List.fold_left(nums, 0, fn (acc, x) -> acc + x)
let first   = List.find(nums, fn x -> x > 10)

|> is the pipe operator — it passes the left side as the first argument to the right side.


Pattern matching replaces isinstance and if/elif

# Python
if isinstance(shape, Circle):
    return 3.14 * shape.r ** 2
elif isinstance(shape, Rect):
    return shape.w * shape.h
else:
    return 0
match shape do
  Circle(r)  -> 3.14159 *. r *. r
  Rect(w, h) -> w *. h
  Point      -> 0.0
end

The compiler checks exhaustiveness — if you add a new constructor to Shape later, every match that doesn’t cover it becomes a compile error.

match do without a subject replaces if/elif/else chains:

match do
  score >= 90 -> "A"
  score >= 80 -> "B"
  _           -> "C"
end

Static typing without the ceremony

You’re used to Python being dynamic. March is fully static but the type checker is mostly invisible:

fn double(n) do n * 2 end       -- inferred: Int -> Int
let xs = [1, 2, 3]              -- inferred: List(Int)
let pair = ("hello", 42)        -- inferred: (String, Int)

Annotate at module boundaries for documentation and better error messages:

fn fetch(id : Int) : Result(User, String) do ... end

Concurrency & shared state

Python reaches for a class with mutable self state, plus threading or asyncio for concurrency. March has no mutable objects — state that changes over time lives in an actor, a process you talk to by message.

# Python
class Counter:
    def __init__(self):
        self.count = 0
    def inc(self, n):
        self.count += n
-- March: state lives in an actor; messages drive the transitions
actor Counter do
  state { value : Int }
  init  { value: 0 }

  on Increment(n : Int) do
    { state with value: state.value + n }
  end
end

You spawn the actor and send it messages; it owns its state and no other code can touch it.

Python March
class with mutable self actor with state { … }
threading.Thread / asyncio task Task.async(fn () -> …) + Task.await
asyncio.gather(...) Task.await_many([...])
module-level / global mutable state a Vault (in-memory key-value store)

See Actors and Choosing a concurrency primitive.


What’s the same

  • println(...) for output
  • List literals: [1, 2, 3]
  • Tuple literals: (1, "hello")
  • Boolean: true, false, &&, ||, !
  • String concatenation: "hello " ++ name (instead of f-strings)
  • Integer arithmetic: +, -, *, /; float arithmetic adds .: +., -., *., /.
march — interactive
Click run on any snippet to try it here.
march>