DEV Community

Trent Morris
Trent Morris

Posted on Edited on

df-script - a 43kB zero-dependency, expression-based DataFrame engine for TypeScript and JavaScript

Docs & Interactive Playground: https://df-script.js.org

AI Agent Docs: https://df-script.js.org/llms-full.txt

GitHub Repository: https://github.com/trentamorris/df-script

Install: npm install df-script

License: MIT

Overview

Heavily inspired by modern columnar engines like Polars and Pandas, df-script packs 300+ declarative operations into a 43kB, zero-dependency DataFrame engine for modern JavaScript and TypeScript environments. It lets you easily execute complex multi-column groupings, relational joins, windowed rolling statistics, string parsing, temporal conversions, and much more with a fluent, expressive API.

Why df-script?

For most web applications, performing complex data transformations requires maintaining a dedicated backend service. Suddenly, an otherwise straightforward application introduces deployment pipelines, network latency, serialization bottlenecks, asynchronous state management, and ongoing cloud hosting bills, all just to run operations modern client CPUs can perform in milliseconds.

To avoid this, many developers implement hand-written functions or native chains of .map(), .filter(), and .reduce() to transform data directly in the browser. These chained operations repeatedly traverse the dataset and flood memory with intermediate objects, degrading performance and freezing the UI. Worse, what starts as a simple utility quickly devolves into hundreds of lines of fragile boilerplate once real-world edge cases surface.

df-script changes this equation entirely. It introduces a 43kB zero-dependency, column-oriented DataFrame engine constructed specifically to enable complex chained data transformations directly in the browser with zero network latency and no server maintenance. By avoiding high-level array iterators like .map() and .reduce() in favor of contiguous columnar loops, df-script drastically cuts intermediate object allocations to reduce garbage collection pressure. Every function is backed by a dedicated 1:1 test suite covering hundreds of extreme edge cases, so you never have to hand-roll fragile data logic again.

Built-In CSV & NDJSON Ingestion + Multi-Metric GroupBys

You don't need external parsers to ingest raw text feeds. df-script parses CSV, JSON, and NDJSON directly into columnar storage and runs declarative aggregations with full TypeScript inference:

import { $df } from "df-script";

const rawCsv = `region,category,revenue,quantity,timestamp
East,Hardware,450,3,2026-02-01
East,Hardware,550,5,2026-02-10
West,Software,1200,1,2026-01-20`;

const salesSummary = $df
  .readCsv(rawCsv)
  .groupBy(["region", "category"])
  .agg([
    $df.col("revenue").sum().alias("total_revenue"),
    $df.col("revenue").mean().round(2).alias("avg_revenue"),
    $df.col("quantity").sum().alias("units_sold"),
    $df.col("timestamp").str.toDatetime().dt.year().alias("year"),
  ])
  .sort("total_revenue", { descending: true });

console.log(salesSummary.toDicts());
/*
+--------+----------+---------------+-------------+------------+------+
| region | category | total_revenue | avg_revenue | units_sold | year |
+--------+----------+---------------+-------------+------------+------+
| West   | Software | 1200          | 1200        | 1          | 2026 |
| East   | Hardware | 1000          | 500         | 8          | 2026 |
+--------+----------+---------------+-------------+------------+------+
*/
Enter fullscreen mode Exit fullscreen mode

Partitioned Window Functions (.over()) & Rolling Metrics

Compute group-partitioned metrics, cumulative sums, rolling statistics, and exponentially weighted moving averages without manual grouping and merging:

const transactions = $df.data([
  { region: "East", revenue: 450 },
  { region: "East", revenue: 550 },
  { region: "West", revenue: 1200 },
]);
/*
transactions:
+--------+---------+
| region | revenue |
+--------+---------+
| East   | 450     |
| East   | 550     |
| West   | 1200    |
+--------+---------+
*/

const ranked = transactions.withColumns([
  $df.col("revenue").sum().over("region").alias("region_total"),
  $df.col("revenue").rowNumber().over("region").alias("region_rank"),
  $df.col("revenue").rollingMean(3).alias("rolling_3_avg"),
]);

console.log(ranked.toDicts());
/*
Output:
+--------+---------+--------------+-------------+---------------+
| region | revenue | region_total | region_rank | rolling_3_avg |
+--------+---------+--------------+-------------+---------------+
| East   | 450     | 1000         | 1           | 450           |
| East   | 550     | 1000         | 2           | 500           |
| West   | 1200    | 1200         | 1           | 733.33        |
+--------+---------+--------------+-------------+---------------+
*/
Enter fullscreen mode Exit fullscreen mode

First-Class Nested Array Expressions (.arr.*)

const df = $df.data({
  user: ["Alice", "Bob"],
  scores: [[72, 88, 95], [60, 65]],
});
/*
df:
+-------+--------------+
| user  | scores       |
+-------+--------------+
| Alice | [72, 88, 95] |
| Bob   | [60, 65]     |
+-------+--------------+
*/

const analyzed = df.withColumns([
  $df.col("scores").arr.filter($df.element().gt(80)).alias("high_scores"),
  $df.col("scores").arr.mean().round(1).alias("avg_score"),
]);

console.log(analyzed.toDicts());
/*
Output:
+-------+--------------+-------------+-----------+
| user  | scores       | high_scores | avg_score |
+-------+--------------+-------------+-----------+
| Alice | [72, 88, 95] | [88, 95]    | 85        |
| Bob   | [60, 65]     | []          | 62.5      |
+-------+--------------+-------------+-----------+
*/
Enter fullscreen mode Exit fullscreen mode

Relational Joins

Join tables (left, inner, outer, semi, anti, and asof) with automatic suffixing:

const orders = $df.data([
  { orderId: 101, customerId: 1, amount: 250 },
  { orderId: 102, customerId: 2, amount: 1400 },
]);
/*
orders:
+---------+------------+--------+
| orderId | customerId | amount |
+---------+------------+--------+
| 101     | 1          | 250    |
| 102     | 2          | 1400   |
+---------+------------+--------+
*/

const customers = $df.data([
  { id: 1, firstName: "Alice", lastName: "Smith" },
  { id: 2, firstName: "Bob", lastName: "Jones" },
]);
/*
customers:
+----+-----------+----------+
| id | firstName | lastName |
+----+-----------+----------+
| 1  | Alice     | Smith    |
| 2  | Bob       | Jones    |
+----+-----------+----------+
*/

const enrichedOrders = orders
  .join(customers, {
    leftOn: "customerId",
    rightOn: "id",
    how: "left",
  })
  .withColumns([
    $df.col("firstName").str.concat(" ").str.concat($df.col("lastName")).alias("fullName"),
    $df.when($df.col("amount").gt(1000)).then("VIP").otherwise("Standard").alias("tier"),
  ]);

console.log(enrichedOrders.toDicts());
/*
Output:
+---------+------------+--------+----+-----------+----------+-------------+----------+
| orderId | customerId | amount | id | firstName | lastName | fullName    | tier     |
+---------+------------+--------+----+-----------+----------+-------------+----------+
| 101     | 1          | 250    | 1  | Alice     | Smith    | Alice Smith | Standard |
| 102     | 2          | 1400   | 2  | Bob       | Jones    | Bob Jones   | VIP      |
+---------+------------+--------+----+-----------+----------+-------------+----------+
*/

Enter fullscreen mode Exit fullscreen mode

df-script is completely free and open-source under the MIT License.

I'd truly appreciate any feedback, edge-case ideas, or thoughts on what you'd like to see added next!

Top comments (0)