<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Wonder-David Efe</title>
    <description>The latest articles on DEV Community by Wonder-David Efe (@wondadav).</description>
    <link>https://dev.to/wondadav</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3858366%2F4fc99971-6504-4a7f-bf5d-d68b7e284ea6.jpg</url>
      <title>DEV Community: Wonder-David Efe</title>
      <link>https://dev.to/wondadav</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wondadav"/>
    <language>en</language>
    <item>
      <title>How do I answer "what did my data look like last month" in Postgres?</title>
      <dc:creator>Wonder-David Efe</dc:creator>
      <pubDate>Thu, 09 Jul 2026 18:06:54 +0000</pubDate>
      <link>https://dev.to/wondadav/how-do-i-answer-what-did-my-data-look-like-last-month-in-postgres-h5h</link>
      <guid>https://dev.to/wondadav/how-do-i-answer-what-did-my-data-look-like-last-month-in-postgres-h5h</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F304kcbgp1ba13moilzu2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F304kcbgp1ba13moilzu2.png" alt="Data versions across timelines" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
Sooner or later, someone asks: &lt;em&gt;"What did this customer's plan look like when they signed up last February?"&lt;/em&gt; The current row won't tell you that; it's been edited three times since. This is the classic historical/temporal data problem: how do you manage changes to dimensional data over time so you can answer &lt;em&gt;"row as of T"&lt;/em&gt; on demand. This article is about the schema pattern that answers that question in one &lt;code&gt;SELECT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There are several approaches to modeling and solving this problem, and I'll discuss some of those approaches below:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;a) Event-driven/event sourcing.&lt;/strong&gt; This has to do with when the domain naturally consists of discrete meaningful facts, e.g., sensor readings, prescriptions, stock movements, financial transactions. Here the change of records events is the natural state and the database is designed for recording these events. It is a Bad fit when the domain thinks in &lt;em&gt;records&lt;/em&gt; (customers, products, contracts) whose changes don't have natural event semantics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;b) Versioning in place&lt;/strong&gt; (this is what SCD Type 2 refers to, if you want to look it up — &lt;a href="https://www.thoughtspot.com/data-trends/data-modeling/slowly-changing-dimensions-in-data-warehouse" rel="noopener noreferrer"&gt;SCD explainer&lt;/a&gt;). The database table holds every version of every record with &lt;code&gt;valid_from&lt;/code&gt; / &lt;code&gt;valid_to&lt;/code&gt; denoting the change time; the domain still thinks in records. Getting the records state at a certain point in time requires filtering for versions in the valid from/to window. Great when versions have domain meaning. Bad fit when the app is already built; adding versioning in place means every existing query, view, and join needs a WHERE valid_to IS NULL filter. In greenfield the choice between this and a sibling history table (SCD Type 4) is close, and comes down to whether "one table with a filter convention" or "current and history split" fits the team's mental model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;c) Snapshotting a full copy on some cadence or event.&lt;/strong&gt; Nightly snapshots into a separate table; or freeze a snapshot into a downstream entity when a business event happens (approval, invoice). This is efficient when the ask is anchored to those specific moments and storage is tight. When asking for arbitrary times ("what did this look like Tuesday at 15:37") and for records that change often, you get the values at snapshot time and miss a lot of the changes in between snapshots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;d) Slowly Changing Dimension (SCD) Type 4.&lt;/strong&gt; In SCD Type 4, you solve by creating a history table in addition to your main table. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;The SQL below is Postgres; the pattern translates to any RDBMS that supports row-level triggers, just with different syntax for the trigger body and the point-in-time query.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So you originally have your main table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;record_id&lt;/span&gt;  &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;-- your app's existing columns&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alongside it, you create a history table that mirrors the same shape plus a few bookkeeping columns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;records_history&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;history_id&lt;/span&gt;  &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;valid_from&lt;/span&gt;  &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;valid_to&lt;/span&gt;    &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;operation&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="k"&gt;INCLUDING&lt;/span&gt; &lt;span class="k"&gt;DEFAULTS&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;records_history&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valid_from&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;LIKE records INCLUDING DEFAULTS&lt;/code&gt; copies every column from &lt;code&gt;records&lt;/code&gt;. The history table auto-inherits the schema. Only the currently-valid version of a record has &lt;code&gt;valid_to = NULL&lt;/code&gt;; older versions have their &lt;code&gt;valid_to&lt;/code&gt; filled in with the moment the next edit closed them out.&lt;/p&gt;

&lt;p&gt;Every INSERT, UPDATE, and DELETE on &lt;code&gt;records&lt;/code&gt; needs to be mirrored into &lt;code&gt;records_history&lt;/code&gt;. A row-level trigger keeps them in sync so the app itself never has to write to two tables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;records_history_trg&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="n"&gt;TG_OP&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'UPDATE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'DELETE'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
        &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;records_history&lt;/span&gt;
           &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;valid_to&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
         &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;record_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;OLD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;record_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;valid_to&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="n"&gt;TG_OP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'DELETE'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
        &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;records_history&lt;/span&gt;
        &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;nextval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'records_history_history_id_seq'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
               &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'DELETE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;OLD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;OLD&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;records_history&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;nextval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'records_history_history_id_seq'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
           &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TG_OP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;records_history_trg&lt;/span&gt;
&lt;span class="k"&gt;AFTER&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;EACH&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;records_history_trg&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On every UPDATE or DELETE the previously-current version has its &lt;code&gt;valid_to&lt;/code&gt; stamped to &lt;code&gt;now()&lt;/code&gt;. On INSERT or UPDATE a fresh row is written with &lt;code&gt;valid_from = now()&lt;/code&gt; and &lt;code&gt;valid_to = NULL&lt;/code&gt;; the new current version. On DELETE we also write a tombstone row with &lt;code&gt;valid_from = valid_to = now()&lt;/code&gt; so we can tell later that the record ceased to exist at exactly that moment.&lt;/p&gt;

&lt;p&gt;With this in place, asking "what did the table look like on date T?" is one query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;records_history&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;valid_from&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;as_of&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;valid_to&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;valid_to&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;as_of&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;operation&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s1"&gt;'DELETE'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;record_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valid_from&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each record we pick the version whose validity window contains &lt;code&gt;:as_of&lt;/code&gt;, and we skip records that were deleted before that moment. The &lt;code&gt;DISTINCT ON (record_id)&lt;/code&gt; with &lt;code&gt;ORDER BY record_id, valid_from DESC&lt;/code&gt; gives you one row per record, the most recent one that was valid at &lt;code&gt;:as_of&lt;/code&gt; which is exactly what a &lt;code&gt;SELECT * FROM records&lt;/code&gt; would have returned if you'd run it at that moment.&lt;/p&gt;

&lt;p&gt;One thing to handle at install time: if rows existed before the trigger was added, they'll have no history entries yet. Backfill once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;records_history&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;valid_from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valid_to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'BOOTSTRAP'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every existing record now has a &lt;code&gt;BOOTSTRAP&lt;/code&gt; version starting at install. Point-in-time queries before that return empty (which is honest as you didn't have history yet). Anything on or after is queryable.&lt;/p&gt;

&lt;p&gt;Two things to bake into ops before you ship. &lt;strong&gt;Schema drift.&lt;/strong&gt; When you add a column to &lt;code&gt;records&lt;/code&gt;, &lt;code&gt;records_history&lt;/code&gt; doesn't gain it automatically. &lt;code&gt;LIKE ... INCLUDING DEFAULTS&lt;/code&gt; copies the schema at CREATE time, not on future changes. The trigger's &lt;code&gt;NEW.*&lt;/code&gt; copy will fail on the column-count mismatch until you &lt;code&gt;ALTER TABLE records_history ADD COLUMN&lt;/code&gt; to match. Bake that ALTER into whatever automation runs your migrations so nobody has to remember. &lt;/p&gt;

</description>
      <category>ai</category>
      <category>dataengineering</category>
      <category>sql</category>
      <category>temporalqueries</category>
    </item>
    <item>
      <title>Filesystem for AI Agents: What I Learned Building One</title>
      <dc:creator>Wonder-David Efe</dc:creator>
      <pubDate>Thu, 02 Apr 2026 22:08:48 +0000</pubDate>
      <link>https://dev.to/wondadav/filesystem-for-ai-agents-what-i-learned-building-one-pm4</link>
      <guid>https://dev.to/wondadav/filesystem-for-ai-agents-what-i-learned-building-one-pm4</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn9ekylzp7fs803hyq2hk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn9ekylzp7fs803hyq2hk.png" alt="AI Systems" width="800" height="470"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Filesystem for AI Agents: What I Learned Building One
&lt;/h2&gt;

&lt;p&gt;Most agentic systems, like Claude Code, that run on laptops and servers, interact with files natively through bash. But building an agentic system that allows users to upload and work with files comes with its own limitations that make you unable to store files on the server the agent runs on, and give the agent the bash tool:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The fact that it's exposed to users anywhere — bad actors can get it to run commands that can crash the server or exploit other stuffs, so you want only file operations&lt;/li&gt;
&lt;li&gt;Even if you allow only file operations, you can't store every user's files on the server due to storage limits, so you'll have to store files in remote storage like S3 or Azure — but mounting them will make native commands like grep slow, as it has to download the full file first&lt;/li&gt;
&lt;li&gt;Even if you had unlimited storage and didn't need mounting, you still need isolation — where the agent cannot access files uploaded by another user, or by the same user in another session&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There are other solutions to these problems, but they each come with their own tradeoffs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VM/sandbox platforms (E2B, Northflank)&lt;/strong&gt; — spin up an isolated environment per conversation, which solves security and isolation. But they have cold start latency, operational overhead, and cost that compound at scale. You're managing servers again, just indirectly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3 mounting (mountpoint-s3, JuiceFS, s3fs)&lt;/strong&gt; — mount remote object storage as a local filesystem. Grep and similar commands work, but inefficiently — each scan triggers sequential HTTP range requests that essentially download the whole file in chunks. Too slow for agent workloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;just-bash (Vercel Labs)&lt;/strong&gt; — a TypeScript reimplementation of bash with a pluggable filesystem backend. Closest to what I wanted, but TypeScript only. My pipeline is Python.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Localsandbox (CoPlane)&lt;/strong&gt; — Python wrapper around just-bash, which would have solved the language problem. But it bridges Python to just-bash via a Deno runtime, adding a deployment dependency I didn't want in a Celery environment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ran into this problem recently while building a legal AI agentic system where users had to upload files for the agent to work with. The solution I needed had to be database-like storage that doesn't need to be spun up and down like a server, but supports native file operations that can be exposed as tools to the agent, with the agent unable to access anything outside its own scoped workspace.&lt;/p&gt;

&lt;p&gt;Then I found AgentFS — a filesystem built specifically for AI agents, backed by Turso/SQLite. It provides scoped, isolated storage per user and session, with file operations that can be wired directly as agent tools.&lt;/p&gt;

&lt;p&gt;Of the integration options — Python SDK, AgentFS + just-bash, AgentFS + FUSE — I went with the Python SDK. Unlike FUSE, which gives the agent a real mount but leaves the rest of the server exposed, the Python SDK puts you in full control. The agent can only do what you explicitly wire up as a tool. No shell escape, no arbitrary commands, no environment variable leaks. The isolation is in the design, not bolted on afterward.&lt;/p&gt;

&lt;p&gt;The trade-off is that you're responsible for the tool surface. The SDK ships with the basics — read, write, list — but search operations were missing. No grep, no find, no wc. For an agent that needs to navigate files without dumping everything into context, those aren't optional. So I built them and raised a PR to have them integrated directly into the SDK.&lt;/p&gt;

&lt;p&gt;AgentFS relies on Turso DB for hosted production use. Locally, the pattern already works — one SQLite file per user, each opened independently with full read-write access. But on a production server, you can't manage hundreds of separate database files manually. You need a single server process that can route connections to the right user's database.&lt;/p&gt;

&lt;p&gt;Turso Cloud solves part of this — it supports creating thousands of separate databases and even lets you query across them using ATTACH. But attached databases are currently read-only. You can read from multiple user databases in one session, but you can't write to them. For an agentic system where the agent needs to create, modify, and delete files in a user's scoped workspace, read-only access isn't enough.&lt;/p&gt;

&lt;p&gt;Turso has confirmed that full read-write ATTACH support is on their roadmap. On the AgentFS side, the open() call goes through a connect() function that can be pointed at a Turso-managed database instead of a local file — so the SDK integration path is straightforward once Turso ships the write support. Until then, full production multi-user AgentFS is blocked on this upstream feature.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>agents</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
