Table of Contents
- Exec Summary
- 1. Toolchain and Dependency Bump
- 2. String and Indexing Semantics Changed
- 3. My No Warnings Policy: Why It Matters
- 4. Pre-submit and modular-community Alignment
- 5. Case Studies: mojo-dotenv and mojo-ini
- 6. Where Reflection Fits (Later)
- Appendix A: Concrete Before/After Examples
- Appendix B: Benchmark Commentary (25.7 → 26.1)
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 tostr.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-allpipeline.
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"inpixi.tomlacross all the libraries.mojo_version = "=0.26.1"in eachrecipe.yamlused 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 anIndexer, and direct indexing is more constrained.- Iterating over
Stringdirectly is deprecated; you're asked to usestr.codepoints()orstr.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_digitsas aList[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 viascripts/run_tests.py.pixi run examples-all→ run all Mojo examples viascripts/run_examples.py.pixi run pre-submit→ run tests, validaterecipe.yaml, build withrattler-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-allinside 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:
- Bump MAX /
mojo_versionin the library repo and its recipe. - Fix code and tests until
pixi run pre-submit --skip-modular-communityis ok with no warnings. - Check out the corresponding modular-community recipe branch (e.g. PR 196 for
mojo-toml). - Update that recipe to 26.1 and run
pixi run build-alllocally. - 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.mojogained small helpers liketo_chars(text: String) -> List[String]andjoin_chars(chars: List[String], start: Int, end: Int) -> String.- Functions such as
strip_inline_comment,expand_variables,process_escapes,strip_quotes, andparse_dotenvnow operate onList[String]built fromvalue.codepoint_slices()instead of indexingStringdirectly. - 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 bothmojo-dotenvandpython-dotenv. - When
python-dotenvlogs messages likepython-dotenv could not parse statement starting at line 6or... 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.
- The Python compatibility tests deliberately feed edge-case fixtures (e.g. invalid lines in
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]andlen(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 useself.charsinstead of indexingself.inputdirectly.
-
- 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 theconfigparserbaseline 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.
- Python-side benchmarks (
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-tomlpattern and removes the last directString[...]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.1Listinitialiser. -
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
asciichartpyand all pass on 26.1. - Two more advanced tests that walked Python lists (
Python.evaluate(...)) and relied on implicitPythonObject → Float64conversion viaFloat64(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.
- The core interop tests (simple linear data, sine wave, height configuration, flat line) still compare Mojo output directly against Python's
- pixi manifest vs CI version:
- Locally I am using a newer pixi which supports the
[workspace]table inpixi.toml. - The modular-community CI is currently pinned to
pixi-version: v0.37.0viaprefix-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 themojo-*repos. Newer pixi emits a deprecation warning but still accepts it, while pixi 0.37 parses it happily.
- Locally I am using a newer pixi which supports the
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:
-
Safer string handling:
- All direct
Stringindexing and iteration was replaced withcodepoint_slices()and explicitList[String]buffers. - This adds a small amount of per-character overhead in the lexer, parser, writer, and tests.
- All direct
-
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:
-
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
Writeror smallconst-like helpers would avoid repeated allocations.
- In hot paths like escape processing, we currently build small
-
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.
- For some operations we convert the same string to a
-
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
Stringallocations).
- 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
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.