{"slug": "extending-polars-with-rust-expression-plugins", "title": "Extending Polars with Rust Expression Plugins", "summary": "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.", "body_md": "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.\n\nThis 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.\n\n**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.\n\n## Why we built this\n\n### What is fenic\n\nfenic 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.\n\nEvery DataFrame operation you write becomes a Polars expression, or a plan over `pl.DataFrame`\n\ns. 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.\n\nThat'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.\n\n### What we wanted to do\n\nThe 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:\n\n**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.\n\nEach 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.\n\n### Where the obvious approaches fell short\n\nThe reflexive way to add a custom operation to Polars is a Python UDF. `map_elements`\n\nfor per-row work, `map_batches`\n\nfor 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`\n\nfor exactly that. But for the text operations above, UDFs cost too much on three axes.\n\n**Speed.** `map_elements`\n\nruns 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.\n\n**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.\n\n**Types.** A UDF's output type is something you assert loosely and hope holds. We needed operations that produce real, declared dtypes, like `List<String>`\n\n, `Struct`\n\n, fixed-size embedding arrays, so the rest of the plan can type-check against them.\n\nThe alternatives were worse. Forking Polars to add native kernels means owning a fork forever. Doing the text work outside the DataFrame, preprocess then load, throws away the laziness and composition that are the whole reason to use Polars in the first place. We wanted these operations to be first-class citizens of the expression engine. Not neighbors of it.\n\n### Why plugins\n\nPolars has a purpose-built answer for exactly this: expression plugins. You write a kernel in Rust, register it, and it becomes a normal `pl.Expr`\n\n. To the engine, it's indistinguishable from a built-in. That checks every box the UDF route missed.\n\nIt runs in-engine, in Rust, over Arrow buffers. Vectorized, parallelizable, eligible for streaming, with no Python and no per-row object round-trip. It returns a `pl.Expr`\n\n, so it composes. Plugins chain with each other and with native ops in one expression tree the engine plans as a whole. It declares its output dtype, so the plan stays typed through the custom step. And Rust gives us a mature ecosystem for the hot loops (`jaq`\n\n, `minijinja`\n\n, `rapidfuzz`\n\n, `tiktoken`\n\n, a markdown parser) without reimplementing any of it.\n\nThe rule we settled on isn't \"rewrite everything in Rust.\" Native Polars stays the fast path. A plugin only fills a gap Polars can't express: a tokenizer, a jq engine, a regex whose capture-group index is itself a column. Even the fuzzy matcher keeps just its six primitive kernels in Rust and composes the higher-order ratios from ordinary Polars expressions.\n\n`pyo3-polars`\n\ngenerates the FFI, the Arrow marshaling, and the keyword-argument bridge, so the cost of writing one is low. We wrote nine.\n\n### What it bought us\n\nThe end state is worth stating before the mechanics. Every one of fenic's text operations is now a native Polars expression. They run inside the engine over shared Arrow memory, they keep their types, and here's the part that matters most. They compose with native Polars ops in a single expression, with zero Python round-trips.\n\nParsing a markdown document, filtering its AST with jq, indexing into the result, and casting it into a typed struct is one expression. Polars plans and executes it in a single pass, custom Rust and built-in list ops side by side. We'll come back to that exact example once the pieces are on the table.\n\nThe rest of this post is how it's built, from the Python registration down to the Arrow memory, using nothing but the real code.\n\n## How it's built: an implementation walkthrough\n\n### The mental model: two thin layers around a contract\n\nA Polars expression plugin is two small pieces of code with a well-defined contract between them:\n\n**Python side.** Register a function so it looks like native Polars:`expr.my_namespace.my_op(...)`\n\n.**Rust side.** A function that takes`&[Series]`\n\n, returns`PolarsResult<Series>`\n\n, and declares its output dtype.\n\nEverything between them is generated for you by `pyo3-polars`\n\n. Moving `Series`\n\nacross the FFI boundary via Arrow, marshaling keyword arguments, wiring the symbol lookup. You never touch the FFI by hand.\n\nHere's the entire Python surface for fenic's `json.jq`\n\noperator:\n\nTwo Polars APIs are doing the work:\n\n`@pl.api.register_expr_namespace(\"json\")`\n\nbolts a`.json`\n\naccessor onto*every*`pl.Expr`\n\nin the process. After import,`pl.col(\"payload\").json.jq(\".name\")`\n\nis a legal expression anywhere Polars expressions are legal.`register_plugin_function(...)`\n\nreturns a normal`pl.Expr`\n\nthat, when the engine evaluates it, dlopens the compiled library at`plugin_path`\n\n, looks up the symbol named`function_name`\n\n, hands it the input`Series`\n\n, and reads back the result.\n\nThat's the whole idea. `.json`\n\nis not special-cased anywhere in Polars. It's a user-registered namespace, and fenic registers nine of them (`json`\n\n, `jinja`\n\n, `markdown`\n\n, `chunking`\n\n, `tokenization`\n\n, `fuzz`\n\n, `regexp`\n\n, `dtypes`\n\n, `transcript`\n\n). The plugin looks exactly like a built-in because, to the expression engine, there's no meaningful difference.\n\n(New to plugins? The [Polars plugin docs](https://docs.pola.rs/user-guide/plugins/expr_plugins/) and the [ pyo3-polars](https://github.com/pola-rs/pyo3-polars) repo are the canonical references.)\n\n### Walking through one plugin, end to end\n\nThe Python `jq`\n\nmethod above points at a Rust symbol called `jq_expr`\n\n. Here it is, in full, on the other side of the boundary:\n\nEverything you need to understand the contract is in that snippet:\n\n**Signature.** The`#[polars_expr(...)]`\n\nattribute macro (from`pyo3_polars::derive`\n\n) turns an ordinary Rust function into an exported, C-ABI symbol that Polars can dlopen and call. Your function just receives`inputs: &[Series]`\n\nand returns`PolarsResult<Series>`\n\n. No`unsafe`\n\n, no manual pointer juggling. The macro generates the Arrow marshaling.**Typed inputs.**`inputs[0].str()?`\n\ndowncasts the first`Series`\n\nto a`StringChunked`\n\n. If the column isn't a string, you get a clean`PolarsError`\n\ninstead of a segfault.**Do work once, not per row.** The jq filter is compiled a single time (`build_jq_query`\n\n) and reused across every row. This is a recurring pattern. Hoist compilation, allocation sizing, and setup out of the row loop.**Build the output with a builder.**`ListStringChunkedBuilder`\n\n, pre-sized with a capacity estimate, produces the output column. The null semantics are deliberate.`append_null()`\n\nfires for a null input row or an empty jq result. A failed jq query aborts the batch with a`ComputeError`\n\n. And malformed JSON hits an`unreachable!()`\n\n, because the input column is typed`JsonType`\n\nand upstream validation guarantees every non-null row is valid JSON. (That guarantee is a nice side effect of building on a typed engine. The kernel gets to assume its inputs.)**Return a**`Series`\n\n.`builder.finish().into_series()`\n\n.\n\nThe `jaq`\n\ncrate (a pure-Rust jq) does the actual filtering. Polars provides the columnar plumbing. The plugin is the seam between them.\n\n### Output types, and why they matter\n\nLook again at the attribute: `#[polars_expr(output_type_func=jq_output)]`\n\n. That `jq_output`\n\nfunction returns `DataType::List(String)`\n\nand never touches the data. This is the single most important thing to get right about plugins, and the easiest to get wrong.\n\nPolars resolves the schema of an expression before it executes it. A plugin is opaque Rust. The engine can't infer what comes out by looking at your loop. So you must declare the output dtype up front, and the declaration has to run without the data. fenic uses all three declaration styles the macro supports, picked by how much the output type depends on.\n\n**1. Static. The type never changes.** All six fuzzy-match kernels always return a `Float64`\n\n:\n\n`tokenization.count_tokens`\n\nis likewise a fixed `#[polars_expr(output_type=UInt32)]`\n\n.\n\n**2. From input fields. The type is a function of the inputs.** `jq`\n\nand text chunking both always produce `List<String>`\n\n, declared by a small function that receives the input `Field`\n\ns (and here ignores them).\n\n**3. From the arguments. The type depends on kwargs.** This is where it gets interesting. fenic's `dtypes.cast`\n\nkernel implements casting into fenic's own logical type system, so its output dtype is literally an argument, passed as a serialized JSON type descriptor. The output-type function deserializes that JSON to compute the Polars dtype at plan time:\n\nThe lesson: `output_type_func_with_kwargs`\n\nlets a plugin's result type be computed from its configuration, decoded at schema-resolution time. That's what makes it possible to bolt an entirely foreign type system (fenic's logical types, including things like fixed-size embedding arrays) onto Polars' physical dtypes while keeping lazy schema resolution intact. Your plugin declares, up front and data-free, exactly what shape it will produce. (One honest caveat, since we're holding this up as exemplary. The real code `.expect()`\n\ns on a malformed type descriptor, so a bad JSON string panics the schema-resolution path rather than surfacing a clean `PolarsError`\n\n. Hardening it would map that to a `ComputeError`\n\n.)\n\nGet this wrong, declare `List<String>`\n\nand then build a `Float64`\n\n, and you don't get a compile error. You get a runtime failure when the produced column doesn't match the promised schema. The declaration is a contract you're on the hook for.\n\n### Getting parallelism and broadcasting right\n\nEvery fenic plugin passes `is_elementwise=True`\n\n. That flag is a promise to the engine: this function treats each row independently. Row `i`\n\nof the output depends only on row `i`\n\nof the input. No ordering, no windows, no cross-row state.\n\nThat promise is worth a lot. It makes the function eligible to run in Polars' streaming engine, and it lets the optimizer reason about it: predicate, projection, and slice pushdown, plus a `group_by`\n\nfast path where the function runs over the flattened series instead of once per group. It is also load-bearing. If you set `is_elementwise=True`\n\non a function that actually looks across rows, Polars may hand it a partial batch (a streaming morsel), or run it over flattened groups inside a `group_by`\n\n. Either of which silently breaks cross-row logic. The flag isn't a hint. It's an invariant you're asserting.\n\n\"Elementwise\" still has to cope with broadcasting. The common case is where one argument is a scalar literal (length 1) and another is a full column. fenic handles this two ways depending on the kernel.\n\nFor the fuzzy matchers, which take two string columns, it leans on a Polars helper that does broadcasting and null handling for you:\n\nTwo things worth stealing here. First, `broadcast_binary_elementwise`\n\nfrom `polars::chunked_array::ops::arity`\n\nhandles length-1 broadcasting and threads `Option<&str>`\n\nthrough so nulls are explicit. The closure decides what a null means (here, null in, null out). Second, note the code comment. We deliberately avoided the `*_values`\n\nvariant that skips the null check, because for UTF-8 string work you do not want the kernel touching the bytes at a null slot. That's a decision you only make after it has already bitten you once.\n\nThe regex kernel takes the manual route because it needs a per-call compile cache anyway:\n\nThe broadcasting is a one-liner (`if pattern_is_literal { 0 } else { i }`\n\n). The `HashMap<String, Regex>`\n\ncompile cache means a literal pattern is compiled once, and every subsequent row is a cache hit, via the `Entry`\n\nAPI:\n\nBecause this cache lives for the duration of one call on one chunk, even a per-row-varying pattern column only ever compiles each distinct pattern once.\n\n### The Arrow boundary, and zero copy\n\nTwo of fenic's plugins, the Jinja renderer and the markdown/JSON path, need to turn arbitrary Arrow values (strings, ints, structs, lists, nested combinations) into a foreign representation. A `minijinja::Value`\n\nfor template rendering, or a `serde_json::Value`\n\nfor JSON emission. This is the point where a naive plugin reaches for `Series`\n\niterators, or worse, materializes Python objects. fenic reads the Arrow arrays directly. It's a deliberate rewrite of an earlier `AnyValue`\n\n-based path that required unnecessary allocations. Reading the Arrow buffer is zero-copy. The only allocation is the one output value the converter actually has to build.\n\nThe whole thing hangs off one trait and one generic function. The trait abstracts \"how do I build a value of type `V`\n\nfrom an Arrow scalar\":\n\nIt's implemented once for `minijinja::Value`\n\nand once for `serde_json::Value`\n\n. Then a single generic converter walks any Arrow array and produces either. The two public methods just pin the concrete type:\n\nA few things worth pulling out:\n\n**It works on** It downcasts to the concrete Arrow array (`&dyn Array`\n\nfrom`polars_arrow`\n\n, not on`Series`\n\n.`Utf8ViewArray`\n\n,`StructArray`\n\n,`ListArray<i32>`\n\n, …) and reads the value at`row_idx`\n\nin place. No per-value boxing into a Polars`AnyValue`\n\n, no Python objects.This is the detail that bites people. Modern Polars represents strings as`Utf8View`\n\nis handled explicitly, alongside`Utf8`\n\nand`LargeUtf8`\n\n.`Utf8View`\n\nby default. But data zero-copy-imported from external Arrow (pyarrow, some IPC/Parquet paths) can still arrive as`Utf8`\n\nor`LargeUtf8`\n\n, which is exactly why the converter matches all three. A plugin that handles only one layout will fall through and error on perfectly ordinary string columns. If you read Arrow arrays directly, you have to know every physical layout Polars might hand you.**Nesting is recursion.**`convert_struct`\n\ncalls`convert`\n\non each field.`convert_list`\n\ncalls`convert`\n\non each element. A`List<Struct<...>>`\n\njust falls out of the recursion. One function handles arbitrarily nested Arrow.**One implementation, two outputs.** Adding a third target (say, a MessagePack value) is one more`impl ArrowToValue`\n\n. The traversal never changes.\n\nThis is the difference between a plugin that works on strings and one that treats the Arrow memory as a first-class input.\n\n### Beyond scalars: returning `List<Struct>`\n\nPlugins aren't limited to scalar columns. fenic's transcript parser takes raw SRT/WebVTT text per row and emits a list of typed cue records. That's a `List<Struct>`\n\ncolumn with a fixed unified schema, regardless of input format. Its output-type function declares the shape of a single cue as a `Struct`\n\n(and validates the format name while it's there):\n\nAnd the rows are assembled with `AnyValue`\n\n, building each struct explicitly and collecting them into a list:\n\nThe kernel builds each cue as an `AnyValue::StructOwned`\n\n, its fields lining up with the cue schema from the output-type function, and collects a row's cues into an `AnyValue::List`\n\n. So the column comes out as `List<Struct>`\n\n. A parse failure on one row becomes a `null`\n\nfor that row, rather than a hard error for the whole batch. Structured output plus honest null semantics is what lets a downstream `.unnest()`\n\nor `.explode()`\n\ntreat the result like any other native nested column.\n\n### Configuration across the boundary\n\nThe bridge for keyword arguments is just serde. On the Python side you build a plain dict (marshaling enums to their string values):\n\nOn the Rust side you declare a `#[derive(Deserialize)]`\n\nstruct with matching field names, and it arrives populated:\n\nPolars serializes the kwargs dict, and the derive macro deserializes it into your struct via serde before your function runs. The only discipline it demands is that the two sides agree on names and shapes. There's no schema enforcing it, so the Python dict and the Rust struct are a contract you keep by hand. `Option<T>`\n\nfields map cleanly to Python `None`\n\n.\n\n### Building and shipping\n\nThe whole thing is one Rust crate compiled to a single dynamic library, built by `maturin`\n\n. The relevant `Cargo.toml`\n\n:\n\n`cdylib`\n\nproduces the loadable library. `rlib`\n\nkeeps it usable from Rust integration tests and benchmarks. `pyo3-polars`\n\nwith the `derive`\n\nfeature is what gives you the `#[polars_expr]`\n\nmacro. Note the version coupling. `pyo3-polars`\n\npins exact `polars`\n\n, `polars-arrow`\n\n, and `pyo3`\n\nversions, and your plugin links against Arrow types that must be ABI-compatible with the Polars that loads it. So the whole stack has to move as one.\n\nOn the Python packaging side, `maturin`\n\nis told to place the built module inside the `fenic`\n\npackage:\n\nSo the compiled artifact lands at `fenic/_polars_plugins.abi3.so`\n\n. That's why every plugin file computes `PLUGIN_PATH = Path(__file__).parents[3]`\n\n. From `.../fenic/_backends/local/polars_plugins/json.py`\n\n, three parents up is `.../fenic`\n\n, the package directory that holds the `.so`\n\n. `register_plugin_function(plugin_path=...)`\n\npoints there, and Polars finds the library by name.\n\nAnd because that Rust stack pins in lockstep, the Python dependency is pinned tight too: `polars>=1.34.0,<1.36.0`\n\n. This is the plugin author's tax. Bump `polars`\n\non its own and you get two incompatible copies of the polars crates in the dependency graph, and a build that fails with type mismatches in every `#[polars_expr]`\n\nfunction. The crates move together, deliberately. A jump to a new Polars can even mean real code changes, not just a lockfile edit.\n\n## The benefits for fenic\n\nEarlier I said the payoff was composition. Now that the pieces are on the table, here it is.\n\n**Everything composes.** Because every plugin returns a plain `pl.Expr`\n\n, plugins compose with each other and with native Polars operations in a single expression. This is fenic's implementation of \"extract code blocks from a markdown document.\" Four operations, three of them custom Rust plugins, one of them a built-in:\n\nThat `.list.get(0)`\n\nsitting between `.json.jq(...)`\n\nand `.dtypes.cast(...)`\n\nis the whole point. To the engine there is no boundary between the plugins and the native op. It's one expression tree, planned once, executed in one pass over the data, entirely inside Polars. No intermediate Python lists, no per-step `Series`\n\n-to-Python-to-`Series`\n\nmarshaling, no leaving the engine and coming back.\n\nIt runs the other direction too, with native expressions feeding a plugin. fenic builds LLM prompts by packing columns into a native `pl.struct`\n\nand handing it to the Jinja plugin:\n\nThe plugin receives a struct column, and the `ArrowScalarConverter`\n\nfrom earlier is what lets the Rust side read that struct's fields as Jinja variables. Native op produces the struct, plugin consumes it, one expression. Once your custom kernels are `pl.Expr`\n\n, the entire Polars expression language, every native string, list, struct, temporal, and arithmetic op, becomes glue you can put between them.\n\n**One build, two surfaces.** A quieter benefit of writing the logic in Rust. You can expose slices of it as plain Python functions too, not only as expressions. fenic validates jq filters and regex patterns at plan time, when a query is being built, before any data flows, using the exact same Rust that executes them:\n\n`py_validate_jq_query`\n\ncalls the same `build_jq_query`\n\nthe `jq_expr`\n\nkernel uses, and raises a Python `ValueError`\n\non a bad filter. So an invalid query is caught the moment the user writes it, with the identical parser that will run it later, rather than blowing up mid-execution. This pattern started with regex. Python's `re`\n\nwas accepting patterns that then failed under Polars' Rust `regex`\n\nengine at runtime, and validating with the same engine ended the divergence. The Rust crate is both a plugin library and an ordinary `pyo3`\n\nextension module. You get both surfaces from one build.\n\nAdd it up, and the plugin system is what let fenic put an entire AI-oriented operation set (tokenizing, templating, parsing, matching, casting) inside Polars rather than beside it. The semantic layer sits on top. The fast, typed, composable substrate underneath is Polars, extended rather than wrapped.\n\n## Lessons for plugin authors\n\nDistilled from building the nine:\n\n**The output-type declaration is a hard contract, unchecked at compile time.** Declare exactly what you build.`output_type_func_with_kwargs`\n\nis the escape hatch when the type depends on configuration, enough to graft a whole foreign type system on, as`dtypes.cast`\n\ndoes.**Match every physical Arrow layout, not just the logical dtype.**`Utf8View`\n\nas well as`Utf8`\n\nand`LargeUtf8`\n\n, or you'll error on perfectly ordinary string columns. And keep the Python kwargs dict and the Rust`Deserialize`\n\nstruct in lockstep. serde is the whole bridge, and nothing but you enforces it.And one you can break silently. While you're at it, hoist setup (compile, allocate, size builders) out of the row loop.`is_elementwise=True`\n\nis an invariant you assert, not a hint.**Split the macro shim from the logic.** Keep`#[polars_expr]`\n\nfunctions tiny and delegate to plain functions (e.g. Jinja's`render(...)`\n\n) that are unit-testable with`cargo test`\n\nand no Python runtime.**Export the same Rust as plain pyfunctions** for plan-time validation, so the code that checks a query is the code that runs it.\n\n## When to reach for a plugin, and where to look next\n\nA plugin is the right tool when you need row-level work that Polars doesn't ship natively, you want it to run inside the engine (parallel, streamable, composable), and you can express the output as a declared dtype. That covers a lot. Parsing, tokenizing, templating, fuzzy matching, format conversion, custom casts. When the work is genuinely not elementwise, or it's an opaque IO-bound call (hitting a model API, say), the honest answer is still `map_batches`\n\nor `map_elements`\n\n, the Python escape hatches, and keeping the schema explicit there instead.\n\nBut for the case where Polars can't do something natively and you want it in-engine and composable, nine plugins deep, the system holds up. It doesn't feel like bolting something onto Polars. It feels like extending it.\n\nIf you want to see all nine in context, fenic is open source. The Rust kernels live in [ rust/src/](https://github.com/typedef-ai/fenic/tree/main/rust/src) and the Python namespaces in\n\n[. If you're writing your own, the](https://github.com/typedef-ai/fenic/tree/main/src/fenic/_backends/local/polars_plugins)\n\n`src/fenic/_backends/local/polars_plugins/`\n\n[Polars plugin docs](https://docs.pola.rs/user-guide/plugins/expr_plugins/)and\n\n[are the place to start. And we'd genuinely like to hear how it goes.](https://github.com/pola-rs/pyo3-polars)\n\n`pyo3-polars`\n\nAnd if this post was worth your time, consider giving [fenic a star on GitHub](https://github.com/typedef-ai/fenic). It's the simplest way to help other people building on Polars find it, and it tells us these deep dives are worth writing.", "url": "https://wpnews.pro/news/extending-polars-with-rust-expression-plugins", "canonical_source": "https://fenic.ai/blog/extending-polars-with-rust-expression-plugins", "published_at": "2026-07-21 21:26:32+00:00", "updated_at": "2026-07-21 21:52:54.409466+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "large-language-models"], "entities": ["fenic", "Polars", "pyo3-polars", "Arrow"], "alternates": {"html": "https://wpnews.pro/news/extending-polars-with-rust-expression-plugins", "markdown": "https://wpnews.pro/news/extending-polars-with-rust-expression-plugins.md", "text": "https://wpnews.pro/news/extending-polars-with-rust-expression-plugins.txt", "jsonld": "https://wpnews.pro/news/extending-polars-with-rust-expression-plugins.jsonld"}}