Final post in the Mojo library development series: Porting Python's asciichartpy (215 lines) to Mojo (548 lines), achieving pixel-perfect compatibility.

Series Context

This is the third and final post in my Mojo library development series:

  • Part 1: Building mojo-dotenv - Configuration management (~200 lines Mojo code, 42 tests)
  • Part 2: Building mojo-toml - TOML parser (~1,500 lines Mojo code, 96 tests)
  • Part 3: This post - ASCII charting (~550 lines Mojo code, 29 tests)

Together, these three projects represent my "Month of Mojo" intensive learning journey, transitioning from simple line-based parsing to complex recursive parsers, and finally to visualisation.

Why ASCII Charts Matter

ASCII charts solve real problems in constrained environments: SSH sessions, CI/CD logs, embedded systems, and terminal monitoring. No GUI required—just fast, lightweight visualisation. They're particularly valuable for:

  • Production monitoring - ML model serving latency tracking
  • CI/CD pipelines - Build time trends without external dashboards
  • Development - Quick visual feedback during prototyping
  • Embedded systems - Resource-constrained environments without graphical capabilities

The Code Size Reality: 2.5x Larger

MetricPythonMojoNotes
Lines of code215 Python548 Mojo2.5x larger
Core logic~180~430Similar complexity
Type declarationsMinimalExplicitMojo requires types everywhere
String formattingf"{x:8.2f}"~60 linesGeneric formatter
Dependenciesstdlib onlystdlib onlyBoth self-contained

Why is Mojo larger?

  1. Missing string formatting - Python's f"{value:8.2f}" required ~60 lines for a generic formatter
  2. Explicit types - Every variable needs var data: List[Float64] declarations
  3. Ownership semantics - Must manage memory with var, owned, borrowed explicitly
  4. No default arguments yet - Requires more function overloads
  5. Verbose struct initialisation - @fieldwise_init boilerplate

The String Formatting Gap (Biggest Challenge)

Python (1 line):

label = f"{value:8.2f}  "

Mojo (60+ lines for generic formatter):

fn format_float(value: Float64, width: Int, precision: Int) -> String:
    """Format a Float64 with specified width and precision.
    
    Mimics Python's format specifier {value:width.precisionf}.
    For example, format_float(12.3456, 8, 2) produces "   12.35".
    """
    var prec = precision if precision >= 0 else 0
    
    # Extract and handle negative values
    var int_part = Int(value)
    var frac_part = value - Float64(int_part)
    var is_negative = value < 0
    if is_negative:
        int_part = -int_part
        frac_part = -frac_part
    
    # Convert fractional part with rounding
    var multiplier = 1.0
    for _ in range(prec):
        multiplier *= 10.0
    var frac_int = Int(frac_part * multiplier + 0.5)
    
    # Handle rounding overflow (e.g., 0.999 -> 1.00)
    if frac_int >= Int(multiplier):
        int_part += 1
        frac_int = 0
    
    # Build string with padding
    var result = String(int_part)
    if prec > 0:
        result += "."
        var frac_str = String(frac_int)
        var padding_needed = prec - len(frac_str)
        for _ in range(padding_needed):
            result += "0"
        result += frac_str
    
    if is_negative:
        result = "-" + result
    
    # Right-align in width
    while len(result) < width:
        result = " " + result
    
    return result

# Usage for chart labels:
fn _format_label(value: Float64) -> String:
    return format_float(value, 8, 2) + "  "

The impact: String formatting accounted for ~60 lines of the size difference. Rather than hardcoding for 8.2f, I built a generic format_float(value, width, precision) function that works for any format.

Why needed? Mojo's string formatting (v0.25.7) doesn't yet support format specifiers like {:8.2f}—it only handles basic {} substitution. The docs note: "Format specifiers for controlling output format (width, precision, and so on)" are not yet supported. Once Mojo adds this, the custom formatter can be replaced with native functionality.

What Worked Brilliantly

1. UTF-8 Support

Mojo handled box-drawing characters (┼├┤╭╮╯╰│─) perfectly—no special encoding needed. This is actually better than C/C++, where Unicode handling often requires external libraries.

2. Python Interop for Validation

Used asciichartpy directly in six interop tests to verify pixel-perfect compatibility:

from python import Python

fn test_simple_comparison() raises:
    # Python
    var py_dotenv = Python.import_module("asciichartpy")
    var py_output = py_dotenv.plot(py_data)
    
    # Mojo
    var mojo_output = plot(mojo_data)
    
    # Verify identical
    assert_equal(String(py_output), mojo_output)

This continuous validation caught subtle differences in banker's rounding that would have been painful to debug in production.

3. ANSI Colour Support

Added six colour themes using Mojo's stdlib (utils._ansi.Color) with zero external dependencies:

from asciichart import plot, Config, ChartColors

fn main() raises:
    var config = Config()
    config.colors = ChartColors.matrix()  # Green terminal theme
    print(plot(data, config))

Adding colours requires zero external dependencies—just Mojo's stdlib.

The Banker's Rounding Gotcha

Python uses IEEE 754 round-half-to-even. Simple floor(x + 0.5) rounding failed:

// Wrong: floor(x + 0.5)
12.5 → 13  // Should be 12
20.5 → 21  // Should be 20

// Correct: Banker's rounding
if diff == 0.5:
    var floor_int = Int(floored)
    rounded = floor_int if floor_int % 2 == 0 else floor_int + 1

Lesson: For pixel-perfect compatibility, match Python's rounding behaviour exactly. This small detail broke output alignment until fixed.

Development Velocity

  • Initial port: 2 hours (core plotting algorithm)
  • Python compatibility: 2.5 hours (formatting, banker's rounding)
  • Testing: 1.5 hours (29 tests including 6 Python interop tests)
  • Colours & benchmarks: 3 hours (v1.1.0 features)

Total: ~10 hours from idea to production-ready library with colours, benchmarks, and verified compatibility.

Practical Example: ML Serving Monitoring

Created examples/ml_serving.mojo demonstrating a realistic production use case:

// Monitor ML model prediction latencies
var latencies = collect_api_latencies()  // Last 100 requests

var config = Config()
config.colors = ChartColors.fire()  // Red/yellow for 'hot' data
print(plot(latencies, config))

// Output includes:
// - Latency trends with spikes visible
// - Statistics: Mean=25.3ms | P95=63.7ms | Max=101.9ms
// - Actionable insights: "High latency detected"

Perfect for SSH sessions, CI/CD logs, or quick health checks without external dashboards.

Lessons for Porting Python to Mojo

  1. Expect 2-3x code size - Missing stdlib features require manual implementation
  2. String formatting is the biggest gap - Budget significant time for this
  3. Python interop is invaluable - Use it for validation tests
  4. Visual testing matters - Automated tests missed spacing issues that only eyeballing caught
  5. Banker's rounding matters - IEEE 754 compliance is not optional for exact compatibility
  6. Performance is a bonus, not the goal - The library is fast enough; focus on correctness and capability first

What This Series Taught Me

Across three projects (dotenv, toml, asciichart), I learned:

Progressive Complexity

  • dotenv: Line-based parsing, string handling basics
  • toml: Recursive parsing, lexer design, complex state management
  • asciichart: Performance optimisation, visual output, benchmark-driven development

Common Patterns

  1. Python interop testing - Gold standard for validation
  2. Explicit typing - Verbose but valuable for correctness
  3. Missing stdlib features - String formatting is the biggest gap across all three projects
  4. Code organisation - Tests should scale with implementation

Performance Reality

The library is fast enough for all practical uses (microseconds for typical charts). While Mojo offers performance advantages, the real value is in having native ASCII charting capability without external dependencies.

The String Formatting Reality Check

Across all three projects, string formatting was the single biggest source of verbosity:

  • dotenv: Minimal impact (simple concatenation only)
  • toml: Moderate impact (error messages with positions)
  • asciichart: Major impact (~60 lines for generic float formatter)

The bottom line: Once Mojo adds format strings to its stdlib, Python→Mojo ports will become significantly easier. The algorithm porting is straightforward—it's the string handling that adds lines.

Conclusion: Is Mojo Ready?

Yes, for libraries where capability matters.

The 2.5x code size is acceptable when you achieve pixel-perfect Python compatibility and zero external dependencies. Mojo is ready for production library work today, not someday.

Would I do it again? Absolutely. The hardest part wasn't the algorithm—it was reimplementing Python's string formatting. Once that's in Mojo's stdlib, porting will be dramatically easier.

For businesses evaluating Mojo: These three libraries (configuration, parsing, visualisation) reduce the barrier to adoption. Focus your migration effort on your actual business logic, not on rebuilding infrastructure.

What's Next

This concludes my Month of Mojo intensive. Three projects, learned the language from scratch, contributed to the ecosystem. Now it's time for some well-earned Australian summer beach time! 🏖️🇦🇺☀️🌊

The repos will continue to be maintained, and I'll be back with fresh energy and ideas.


Try it yourself:

git clone https://github.com/databooth/mojo-asciichart
cd mojo-asciichart
pixi install
pixi run example-ml-serving  # See realistic monitoring example
pixi run bench-python-comparison  # Verify performance claims

Project Links:

Licence: Apache 2.0


Building high-performance data and AI services with Mojo at DataBooth. Questions or want to collaborate? Get in touch.