Extending Polars with Rust Expression Plugins Fenic, a semantic DataFrame library for AI and LLM pipelines built on Polars, developed nine Rust expression plugins to extend Polars' native capabilities for text operations like chunking, prompt templating, jq queries, fuzzy matching, and markdown parsing. The plugins, written via pyo3-polars, replace slow Python UDFs with native expressions that run in-engine over Arrow, preserve declared types, and compose with built-in operations in a single expression tree. Fenic's CEO stated that Python UDFs cost too much on speed, composition, and type safety for these workloads. fenic is a semantic DataFrame library. A PySpark-style API for building AI and LLM pipelines over messy, unstructured data. Its local engine is Polars. This post is about one specific problem we hit while building it, and the part of Polars that solved it: the expression plugin system. We ended up writing nine Rust plugins that extend Polars' expression engine. What follows is both why we went that route and how they're built, with the real code. tl;dr. The operations an AI pipeline needs over text chunking, prompt templating, jq, fuzzy matching, markdown and transcript parsing, richer type casts aren't in Polars. Doing them as Python UDFs is slow, and it breaks composition. Writing them as Polars expression plugins in Rust, via pyo3-polars https://github.com/pola-rs/pyo3-polars , turns them into native expressions. They run in-engine over Arrow, they keep their declared types, and they compose with built-in ops in a single expression tree. If you're weighing a UDF against a plugin, this is the case for the plugin. Why we built this What is fenic fenic is a DataFrame library for building AI and LLM pipelines, with an API modeled on PySpark. If you come from Polars, the important thing to know is that Polars is the core execution engine for fenic. Every DataFrame operation you write becomes a Polars expression, or a plan over pl.DataFrame s. The semantic operators, the LLM map/extract/classify, the embeddings, the similarity joins, all sit on top of that same machinery. So in practice, what fenic can do is bounded by what we can express in Polars. That's a deliberate bet. Polars gives us a fast, columnar, Arrow-native engine with a real expression language and a lazy optimizer. We had no interest in rebuilding any of it. We wanted to add to it. What we wanted to do The workloads fenic targets are pipelines over unstructured text. Documents, chat logs, transcripts, scraped JSON, markdown. Concretely, that means row-level operations Polars has no native equivalent for: Chunk a document into overlapping windows sized by token count, for embedding or retrieval. Render a prompt template per row. Real Jinja, with a struct of columns as the variables, ahead of an LLM call. Query JSON columns with jq , and parse markdown into a structured AST. Fuzzy-match strings six edit-distance metrics for dedup and joins. Parse transcripts SRT/WebVTT into typed, timestamped cue records. Cast values into fenic's richer logical types embeddings, markdown, typed structs that Polars' physical dtypes don't model directly. Each of these has to run over a whole column, produce a typed result, and slot into the middle of a larger DataFrame pipeline. Not sit off to the side. Where the obvious approaches fell short The reflexive way to add a custom operation to Polars is a Python UDF. map elements for per-row work, map batches for whole-Series work. For genuinely opaque, IO-bound work, like an LLM API call, that's still the right tool, and fenic uses map batches for exactly that. But for the text operations above, UDFs cost too much on three axes. Speed. map elements runs Python per row, under the GIL, with a Python-object round-trip for every value. For tokenizing or fuzzy-matching millions of rows, that's the bottleneck. Not the work itself. Composition. This is the one that actually hurt. Polars is fast because it plans and executes an expression tree as a whole. The moment one step is an opaque Python callback, the engine can't see through it. It becomes an optimization barrier, forces a materialization, and breaks the single-pass pipeline. Chain three UDFs and you've got three trips out of the engine and back. Types. A UDF's output type is something you assert loosely and hope holds. We needed operations that produce real, declared dtypes, like List