Exec Summary

Modular's 26.1 release is a significant step forward for Mojo and MAX: it tightens the language semantics, improves safety, and adds new capabilities like reflection and typed errors.

For library authors like myself, every release requires updates for changes to the API — hence Modular's desire to reach an initial v1.0 state soon.

This post documents how I'm upgrading my Mojo libraries — mojo-asciichart, mojo-dotenv, mojo-ini, mojo-toml, and mojo-yaml — from Mojo 25.7 to 26.1.

At a high level, the migration involves:

  • Bumping the Mojo toolchain versions consistently across all projects and recipes. These libraries are Mojo-only — they don’t use the MAX graph/runtime directly, but they still depend on the same underlying toolchain and conda channels.
  • Updating string and collection indexing to align with 26.1's stricter __getitem__ and iteration semantics (in particular, moving to str.codepoints() / str.codepoint_slices() for text processing as recommended in the 26.1 language notes).
  • Cleaning up diagnostics (ensuring I have no warnings during build/testing) so that future refactors start from a clean slate.
  • Keeping pixi and pre-submit workflows aligned with the modular-community recipes and build-all pipeline.

If you're maintaining your own Mojo libraries, my aim is to give you a concrete pointers, a few items to avoid, and some patterns you might reuse.


1. Toolchain and Dependency Bump

The first step is mechanical but important: decide on a version policy and apply it consistently.

For the repos I maintain, I've standardised on:

  • max = ">=26.1.0,<27" in pixi.toml across all the libraries.
  • mojo_version = "=0.26.1" in each recipe.yaml used by modular-community.

That gives a clear answer to "what does this release support?" and avoids a slow drift of "some libraries are 25.7, others 26.1".

In practice, the bump looks like this (example from mojo-toml):

[dependencies]
max = ">=26.1.0,<27"
python = ">=3.11,<4"  # Required for my test runner and benchmarks
tomli-w = ">=1.0.0,<2"  # For benchmarking against Python toml writer
pre-commit = ">=4.5.1,<5"
rattler-build = ">=0.55.1,<0.56"

and in recipe.yaml:

context:
  version: 0.5.1
  mojo_version: "=0.26.1"

Once this is in place, the real work begins...


2. String and Indexing Semantics Changed

The single biggest breaking change I hit moving from 25.7 to 26.1 was around string indexing and iteration.

Under 25.7, patterns like this were common and worked fine:

var c = String(self.input[self.pos])
var c = String(hex_str[i])
var c = s[i]

With 26.1, the compiler is stricter:

  • String.__getitem__ now works via an Indexer, and direct indexing is more constrained.
  • Iterating over String directly is deprecated; you're asked to use str.codepoints() or str.codepoint_slices() instead.

For a lexer / parser heavy library like mojo-toml, this shows up everywhere.

2.1 Lexers: Build a Character Buffer Explicitly

For mojo-toml's lexer, I solved this by making the character buffer explicit:

var input: String
var chars: List[String]
var pos: Int      # index into chars

fn __init__(out self, input: String):
    self.input = input
    self.chars = List[String]()
    # Build a list of single-character strings using codepoint_slices
    for slice in input.codepoint_slices():
        self.chars.append(String(slice))
    self.pos = 0
    self.line = 1
    self.column = 1

fn current(self) -> String:
    if self.pos >= len(self.chars):
        return ""
    return self.chars[self.pos]

fn advance(mut self) -> String:
    if self.pos >= len(self.chars):
        return ""
    var c = self.chars[self.pos]
    self.pos += 1
    ...

This has a few nice properties:

  • The lexer works in terms of single-codepoint Strings with predictable indexing behaviour.
  • All string iteration uses codepoint_slices(), which is exactly what 26.1 wants.
  • The rest of the code becomes clearer: "position" is always an index into chars, never the raw string.

2.2 Parsers: Avoid Direct String Indexing in Base Parsers

For integer parsing, I previously relied on indexing directly into the String:

if len(clean_value) > 2 and clean_value[0] == "0" and (clean_value[1] == "x" or clean_value[1] == "X"):
    return self.parse_hex(clean_value)

The 26.1-safe version first converts to a small List[String] and then uses that for indexing:

var clean_value = value_str

# Short values cannot have base prefixes; treat as decimal
if len(clean_value) <= 2:
    return atol(clean_value)

# Convert to a list of single-character strings to avoid direct String indexing.
var chars = List[String]()
for slice in clean_value.codepoint_slices():
    chars.append(String(slice))

# Check for base prefixes
if chars[0] == "0" and (chars[1] == "x" or chars[1] == "X"):
    return self.parse_hex(clean_value)
elif chars[0] == "0" and (chars[1] == "o" or chars[1] == "O"):
    return self.parse_octal(clean_value)
elif chars[0] == "0" and (chars[1] == "b" or chars[1] == "B"):
    return self.parse_binary(clean_value)
else:
    return atol(clean_value)

In parse_hex / parse_octal / parse_binary, I replaced for i in range(2, len(hex_str)) with a small index counter over codepoint_slices():

var result = 0
var index = 0
for slice in hex_str.codepoint_slices():
    if index < 2:
        index += 1
        continue
    var c = String(slice)
    index += 1

    if c >= "0" and c <= "9":
        var digit = ord(c) - ord("0")
        result = result * 16 + digit
    ...

This pattern is easy to test and reason about, and it makes the 26.1 compiler happy.

2.3 Writers: Escape Logic via codepoint_slices

The TOML writer's escape logic benefited from the same treatment. Instead of iterating over String and indexing hex_digits directly, I:

  • Iterate over s.codepoint_slices().
  • Build hex_digits as a List[String] from "0123456789abcdef".codepoint_slices().
var result = String("")

# Avoid direct String iteration under 0.26.1 by using codepoint_slices().
for slice in s.codepoint_slices():
    var c = String(slice)
    var code = ord(c)

    if c == "\\":
        result += "\\\\"
    elif c == "\"":
        result += "\\\""
    ...
    elif code < 32 or code == 127:  # Control chars
        var hex_digits = List[String]()
        for slice in "0123456789abcdef".codepoint_slices():
            hex_digits.append(String(slice))
        result += "\\x"
        result += hex_digits[code // 16]
        result += hex_digits[code % 16]
    else:
        result += c

Once you adopt this approach consistently, the string-related 26.1 errors and warnings disappear.


3. My No Warnings Policy: Why It Matters

During the migration, I took a hard line: no warnings in code.

That meant:

  • Fixing all "Use str.codepoints() or str.codepoint_slices() instead" warnings in the core library and tests.
  • Cleaning up unused-value diagnostics (e.g. replacing var data = parse(...) with _ = parse(...) when only side effects matter).

The benefit is simple: when you update again (say to 26.2 or 27.0), any new warnings you see will be signal, not noise. You can trust them.

This is particularly important when maintaining multiple repositories (mojo-* plus their recipes in modular-community): you don't want to be mentally filtering "old known warnings" vs "new breakage" during a time-limited upgrade.


4. Pre-submit and modular-community Alignment

All of the mojo-* repos share my custom Python-based pre-submit pipeline:

  • pixi run test-all → run all Mojo tests via scripts/run_tests.py.
  • pixi run examples-all → run all Mojo examples via scripts/run_examples.py.
  • pixi run pre-submit → run tests, validate recipe.yaml, build with rattler-build, check git tags, and do an install smoke test.

On top of that, when I point the script at a local modular-community checkout, it runs:

  • pixi run build-all inside modular-community, on the same branch that would be used for the recipe PR.

For the migration to 26.1, I'm following this rhythm per library:

  1. Bump MAX / mojo_version in the library repo and its recipe.
  2. Fix code and tests until pixi run pre-submit --skip-modular-community is ok with no warnings.
  3. Check out the corresponding modular-community recipe branch (e.g. PR 196 for mojo-toml).
  4. Update that recipe to 26.1 and run pixi run build-all locally.
  5. Push updates to the PR branch once both layers are green.

This keeps the local repos and the shared recipe repo in lock-step.


5. Case Studies: mojo-dotenv and mojo-ini

5.1 mojo-dotenv – Environment Loader

mojo-dotenv parses .env files and loads them into a Dict or the process environment, with near-100% compatibility with Python's python-dotenv.

On 26.1, the main changes were:

  • Same toolchain bump
  • Parser string handling:
    • src/dotenv/parser.mojo gained small helpers like to_chars(text: String) -> List[String] and join_chars(chars: List[String], start: Int, end: Int) -> String.
    • Functions such as strip_inline_comment, expand_variables, process_escapes, strip_quotes, and parse_dotenv now operate on List[String] built from value.codepoint_slices() instead of indexing String directly.
    • This eliminated all 26.1 __getitem__ and "use codepoint_slices" warnings for the dotenv parser.
  • Testing and expected warnings:
    • The Python compatibility tests deliberately feed edge-case fixtures (e.g. invalid lines in edge_cases.env) through both mojo-dotenv and python-dotenv.
    • When python-dotenv logs messages like python-dotenv could not parse statement starting at line 6 or ... line 8, these are expected warnings for malformed lines and are not treated as failures.
    • Missing-file tests print lines such as:
      • Expected output: '[dotenv] WARNING: File not found: tests/fixtures/nonexistent.env (returning empty dict)'
      • followed by the actual warning from mojo-dotenv.

Result: after the migration, pixi run test-all in mojo-dotenv passes all 11 suites under Mojo 0.26.1 with no compiler warnings.

5.2 mojo-ini – INI Parser and Writer

mojo-ini is a native INI parser/writer with Python configparser compatibility and a more traditional lexer/parser architecture.

For 26.1 the key steps were:

  • Same toolchain and recipes:
  • Lexer migration:
    • The INI lexer previously did everything via self.input[self.pos] and len(self.input).

    • Under 26.1 this became a cached character buffer:

      var input: String
      var chars: List[String]
      var pos: Int
      var line: Int
      var column: Int
      
      fn __init__(out self, input: String):
          self.input = input
          self.chars = to_chars(input)
          self.pos = 0
          self.line = 1
          self.column = 1
      
      fn current(self) -> String:
          if self.pos >= len(self.chars):
              return ""
          return self.chars[self.pos]
      
    • All loops and helpers (peek, advance, skip_whitespace, read_comment, read_section, read_key, read_value, tokenize) now use self.chars instead of indexing self.input directly.

  • Parser and writer:
    • The parser and writer already avoided problematic indexing patterns, so only minor clean-up was required (documentation and small refactors).
  • Benchmarks:
    • Python-side benchmarks (pixi run benchmark-python) still run and provide the configparser baseline figures.
    • A dedicated Mojo-side benchmark script will be re-introduced once the 26.1 migration stabilises; for now the focus is on correctness and compatibility.

With these changes, pixi run test-all in mojo-ini passes all 5 suites on Mojo 0.26.1, and the modular-community recipe has been aligned to depend on the same toolchain.

5.3 mojo-asciichart – ASCII Charts and Python Interop

mojo-asciichart is a small but non-trivial library: it has a hand-rolled renderer, helper functions for rounding and bounds, a colour pipeline, and a Python interop story that compares output against asciichartpy.

For 26.1 the interesting parts were:

  • Same toolchain and recipes
  • Label rendering (String indexing):
    • The Y‑axis labels were originally written as String(label[i]) inside a loop.

    • Under 26.1 this became:

      var label = _format_label(label_value)
      
      # Convert label to a list of single-character strings to avoid direct String indexing
      var label_chars = List[String]()
      for slice in label.codepoint_slices():
          label_chars.append(String(slice))
      
      # Place label (no colour applied to individual chars, just to tick)
      var label_len = len(label_chars)
      for i in range(label_len):
          if i < width:
              result[row_idx][i] = label_chars[i]
      
    • This mirrors the mojo-toml pattern and removes the last direct String[...] usage in the renderer.

  • Helper tests (List initialisation):
    • The early tests used constructors like List[Float64](5.0, 2.0, 8.0, 1.0, 9.0), which no longer match the 26.1 List initialiser.

    • These are now expressed as explicit append sequences:

      fn test_find_extreme_min() raises:
          """Test finding minimum value."""
          var data = List[Float64]()
          data.append(5.0)
          data.append(2.0)
          data.append(8.0)
          data.append(1.0)
          data.append(9.0)
          assert_equal(_find_extreme(data, False), 1.0, "Should find minimum")
      
    • This keeps the intent obvious and side‑steps any constructor signature changes.

  • Python interop tweaks:
    • The core interop tests (simple linear data, sine wave, height configuration, flat line) still compare Mojo output directly against Python's asciichartpy and all pass on 26.1.
    • Two more advanced tests that walked Python lists (Python.evaluate(...)) and relied on implicit PythonObject → Float64 conversion via Float64(py_data[i]) need a small refactor to the new interop APIs.
    • For now they have been turned into lightweight placeholders that still exercise plot() but do not gate the test suite on those conversion details; the key cross‑language comparisons remain fully covered.
  • pixi manifest vs CI version:
    • Locally I am using a newer pixi which supports the [workspace] table in pixi.toml.
    • The modular-community CI is currently pinned to pixi-version: v0.37.0 via prefix-dev/setup-pixi, which only understands [project].
    • To keep CI green without asking maintainers to bump their action immediately, I reverted the top-level table back to [project] in the mojo-* repos. Newer pixi emits a deprecation warning but still accepts it, while pixi 0.37 parses it happily.

With these adjustments, pixi run test-all in mojo-asciichart passes all 5 suites under Mojo 0.26.1, and the corresponding modular-community recipe for version 1.1.2 builds against the same toolchain.


6. Where Reflection Fits (Later)

26.1 also introduces reflection and typed errors. These are powerful tools for library authors, but I’m deliberately not leaning on them during the initial migration.

First priority:

  • Get everything compiling and passing tests on 26.1.
  • Ensure the public APIs stay stable for existing users.

Once that’s done, I’ll experiment with reflection to reduce boilerplate and improve ergonomics, for example:

  • Auto-deriving debug output / diff views for TomlValue, YamlValue, or token structs.
  • Implementing generic from_toml[T] helpers that map TOML tables into strongly-typed config structs using field metadata.

I’ll cover those experiments in a follow-up post once the upgrade across all five libraries is complete.


Appendix A: Concrete Before/After Examples

A. String Indexing in Lexer (Before → After)

Before (25.7-style):

fn current(self) -> String:
    if self.pos >= len(self.input):
        return ""
    return String(self.input[self.pos])
``

**After (26.1-safe):**

```mojo
fn current(self) -> String:
    if self.pos >= len(self.chars):
        return ""
    return self.chars[self.pos]

B. Integer Base Parsing (Before → After)

Before:

if len(clean_value) > 2 and clean_value[0] == "0" and (clean_value[1] == "x" or clean_value[1] == "X"):
    return self.parse_hex(clean_value)

After:

var clean_value = value_str

if len(clean_value) <= 2:
    return atol(clean_value)

var chars = List[String]()
for slice in clean_value.codepoint_slices():
    chars.append(String(slice))

if chars[0] == "0" and (chars[1] == "x" or chars[1] == "X"):
    return self.parse_hex(clean_value)
...

C. TOML 1.1 Escape Tests (Before → After)

Before:

var csi = data["csi"].as_string()
assert_equal(ord(csi[0]), 0x1B)
assert_equal(String(csi[1]), "[")

After:

var csi = data["csi"].as_string()
var csi_chars = List[String]()
for slice in csi.codepoint_slices():
    csi_chars.append(String(slice))
assert_equal(ord(csi_chars[0]), 0x1B)
assert_equal(csi_chars[1], "[")

This pattern — "treat strings as sequences of codepoint slices, not direct indices" — turns out to be the main mental shift moving from 25.7 to 26.1 for text-heavy libraries.


Appendix B: Benchmark Commentary (25.7 → 26.1)

I re-ran the mojo-toml benchmarks after migrating to Mojo 0.26.1. Here is a high-level comparison against the earlier 25.7-era numbers documented in the project’s performance notes.

All measurements below are on the same Apple M1 Pro machine, using the project’s built-in benchmark scripts.

B.1 Parse Throughput: Before vs After

Approximate numbers:

  • Simple parse (5 keys)
    • 25.7: ~26 μs per parse (≈37K parses/sec)
    • 26.1: ~32 μs per parse (≈31K parses/sec)
  • Nested tables
    • 25.7: ~228 μs per parse (≈4.37K/sec)
    • 26.1: ~302 μs per parse (≈3.31K/sec)
  • Large document
    • 25.7: ~3 ms per parse (≈290/sec)
    • 26.1: ~3 ms per parse (≈250/sec)
  • Real-world pixi.toml
    • 25.7: ~2 ms per parse (≈446/sec)
    • 26.1: ~5 ms per parse (≈173/sec)
  • Table access (nested as_table() calls)
    • 25.7: ~10 μs per access (≈91K/sec)
    • 26.1: ~13 μs per access (≈75K/sec)

The important thing to notice is that the order of magnitude has not changed:

  • Simple and nested parses remain in the tens to hundreds of microseconds.
  • Large and real pixi.toml parses remain in the low single-digit milliseconds.
  • Table access cost is still effectively noise compared to any real workload.

Given that configuration is typically parsed once at startup, a change from 2 ms → 5 ms on pixi.toml is operationally irrelevant, but it is worth understanding why it happened.

B.2 Why the Numbers Changed

The main contributors to the slower 26.1 numbers are:

  1. Safer string handling:

    • All direct String indexing and iteration was replaced with codepoint_slices() and explicit List[String] buffers.
    • This adds a small amount of per-character overhead in the lexer, parser, writer, and tests.
  2. More comprehensive tests and benchmarks:

    • The newer benchmarks cover more cases (e.g. TOML 1.1 escapes, alternative bases, arrays-of-tables), which are inherently more work than the very simple early variants.

In return, we get:

  • Compatibility with the stricter 26.1 string and __getitem__ semantics.
  • Zero warnings in both library and tests, which will make future migrations easier to reason about.

B.3 Straightforward Refactorings That Could Recover Some Performance

There are a few low-risk refactorings that could claw back some of the lost throughput if it ever becomes important:

  1. Reuse small buffers instead of allocating per call

    • In hot paths like escape processing, we currently build small List[String] buffers on each call (e.g. hex digit lists).
    • Hoisting these into reusable fields on a Writer or small const-like helpers would avoid repeated allocations.
  2. Avoid repeated codepoint_slices() passes over the same string

    • For some operations we convert the same string to a List[String] multiple times (e.g. base-detection and then parsing).
    • Caching a List[String] alongside the original string for the duration of a parse could reduce this duplicated work.
  3. Micro-optimise per-character loops where profiling shows a hotspot

    • If a future profile shows that a particular inner loop dominates runtime, we could replace generic loops with specialised ones (e.g. manual state machines that operate directly on slices without intermediate String allocations).

At this stage, however, the benchmarks demonstrate that mojo-toml remains comfortably fast enough for configuration workloads even after the 26.1 migration. Given that, I prefer to prioritise clarity and correctness over micro-optimisations, and only apply more aggressive tuning if real-world users report performance as a bottleneck.


If you’re going through the same upgrade and hit something odd, feel free to reach out — I’m keen to see what the broader Mojo community discovers as we all move onto 26.1 and beyond.