A formula in Notion is one of the most satisfying things you can build. You write a few lines, and suddenly a column fills itself in across every row. Status labels, totals, days remaining, a clean "Active" or "Churned". It looks like the database is thinking for you.
Then you connect an automation to it, and things start going wrong in ways that are genuinely hard to debug. The sync writes nothing. Or it writes the wrong value. Or it works for three weeks and then a number that drove a decision quietly changes out from under you. The workflow shows green the whole time.
I covered formula columns briefly in the Notion database mistakes that break automations. This post goes all the way down on that one topic, because formula and rollup properties are responsible for a large share of the "the automation is acting weird" tickets I get called in to diagnose. The short version: a formula is a display tool, not a storage tool, and the moment you treat it as storage your automation is standing on sand.
The formula that looked right and broke the sync
Here is a formula I have seen in some version on almost every workspace I have audited. A property called "Status Label" that turns a couple of inputs into a human-readable status.
// A Notion formula property called "Status Label".
// It looks completely correct inside Notion:
if(
prop("Paid"),
"Paid",
if(now() > prop("Due Date"), "Overdue", "Pending")
)
// In the Notion UI this shows a clean "Paid", "Overdue", or "Pending".
// Your automation wants to read that label and branch on it.
// This is exactly where it quietly goes wrong.Inside Notion this is perfect. Every row shows "Paid", "Overdue", or "Pending" without anyone touching it. So when it comes time to build an automation that, say, sends a reminder for overdue invoices, the obvious move is to read "Status Label" and branch on it.
That obvious move is where it breaks. Not because the formula is wrong, but because a formula property behaves nothing like the text property it appears to be. It has a different shape when you read it, it cannot be written at all, and its value is not yours to control. Each of those is a separate trap, so let me take them one at a time.
Trap one: formula and rollup properties are read-only to the API
This is the most fundamental one, and the one people discover the hard way. Formula and rollup properties are computed by Notion. You cannot set their values through the API. If your automation tries to write to a formula property, the request fails.
// Trying to WRITE a formula or rollup property through the API.
// Formula and rollup values are computed by Notion. They are read-only.
// PATCH /v1/pages/{page_id}
{
"properties": {
"Status Label": {
"formula": { "string": "Paid" }
}
}
}
// Notion rejects this. You cannot set a formula's output.
// The fix is not to retry. The fix is to stop storing a value your
// automation needs to write inside a property it can never write to.The instinct when a node errors is to look at retries, auth, or rate limits. None of those are the problem here. The request is structurally invalid: you are asking Notion to overwrite a value it computes for itself. No amount of retrying changes that. I wrote a whole post on handling failures in n8n, but this is a failure you fix by changing the design, not by catching the error more gracefully.
The deeper issue is what this reveals about where your data actually lives. If the value your automation needs to set is the output of a formula, then the source of truth for that value is locked inside Notion's formula engine, where your automation has no write access. You have built a system where the thing you most need to control is the one thing you cannot touch.
Trap two: the nested, type-dependent shape
Reading a formula is allowed. Reading it correctly is where most people slip. A text property returns its value more or less where you expect. A formula property wraps the value in two layers, and the inner key changes depending on what the formula returns.
// What the Notion API actually returns for that formula property:
{
"Status Label": {
"id": "a1b2",
"type": "formula",
"formula": {
"type": "string",
"string": "Overdue"
}
}
}
// The value you want is NOT at .formula
// It is at .formula.string — because .formula.type === "string"
//
// If the formula returned a number -> .formula.number
// If it returned a date -> .formula.date.start
// If it returned a boolean -> .formula.booleanThe value is not at .formula. It is at .formula.string, because .formula.type is "string". If the formula returns a number, the value moves to .formula.number. A date lives at .formula.date.start. A checkbox-style result is at .formula.boolean.
.formula.string, not .formula. Read the wrong key and you get an object, not the text "Overdue".This matters because the most common bug is silent, not loud. Someone maps "Status Label" into a downstream field, reads .formula, and gets the wrapper object. In some tools that stringifies to [object Object]. In others it passes a structure where a string was expected and the comparison just never matches, so the overdue branch never fires. Nothing errors. The automation simply does nothing useful, forever.
The fix is to read the value by the type the formula declares, every time:
// Reading a Notion formula value correctly in an n8n Code node.
// Never read properties["Status Label"].formula directly — that is the
// wrapper object, not the value. Read the key named by formula.type.
const prop = $input.first().json.properties["Status Label"];
function readFormula(p) {
if (!p || p.type !== "formula") return null;
const f = p.formula;
if (f.type === "string") return f.string;
if (f.type === "number") return f.number;
if (f.type === "boolean") return f.boolean;
if (f.type === "date") return f.date ? f.date.start : null;
return null;
}
return [{ json: { status: readFormula(prop) } }];There is a subtle trap inside the trap here. If you edit the formula later so it returns a number instead of a string, the read path silently changes from .formula.string to .formula.number. Any automation that hardcoded the old path breaks the moment you change the formula's output type, with no warning. Reading by formula.type rather than a hardcoded key is what protects you from that.
Trap three: rollups that change under you
Rollups deserve their own warning because they fail in a way that feels almost supernatural. A rollup aggregates values from a related database: the sum of related payments, the count of related tasks, the latest date across linked records. The value is real and correct at the moment you read it. The problem is that the moment you read it is the only moment it is guaranteed to hold.
// A rollup called "Total Paid" that sums a related Payments database.
// You read it on Monday and act on it:
{ "Total Paid": { "type": "rollup", "rollup": { "type": "number", "number": 4200 } } }
// On Tuesday someone edits, deletes, or adds a related payment row.
// The rollup recomputes silently — the page itself was never touched:
{ "Total Paid": { "type": "rollup", "rollup": { "type": "number", "number": 3900 } } }
// Nothing changed "last_edited_time" on this page. If your automation
// keyed a decision on 4200, that decision is now based on a number
// that no longer exists anywhere.When any related row changes, the rollup recomputes. The page that holds the rollup was never edited, so its last_edited_time does not move. If your automation keyed a decision on the value it read last Monday, that value may simply not exist anywhere by Tuesday. You did not make a mistake. The number drifted.
This is the same class of problem I described in automation drift: the workflow keeps running, keeps showing green, and the data underneath it stops being true. Rollups are a built-in source of drift because they are designed to always reflect the current state of related data, which is exactly the wrong property for a value you want to capture and act on at a specific point in time.
The rule I follow: rollups are for humans to look at, not for automations to depend on. If an automation needs the value, it should compute it itself at the moment it acts, or read the rollup once and immediately write the result into a plain property that will not move.
Trap four: formula dates and the timezone off-by-one
Formulas that produce or format dates carry a hidden timezone problem that produces some of the most maddening bugs in this whole category, because the data looks correct in Notion and correct in your automation, just for different days.
// Formula dates vs API timestamps: a classic off-by-one.
// Notion formula: formatDate(prop("Created"), "YYYY-MM-DD")
// Workspace timezone is Asia/Kolkata, so the UI shows: 2026-06-16
// The API returns the underlying timestamp in UTC:
// "2026-06-15T19:05:00.000Z"
new Date("2026-06-15T19:05:00.000Z").toISOString().slice(0, 10);
// -> "2026-06-15" a different calendar day than Notion displayed
// If you select "today's records" by string-matching the date,
// every row created after ~05:30 local time lands on the wrong day.
// Normalize to one timezone in n8n before you compare. Never trust
// a formatted formula date as a machine-readable value.Notion renders formula dates in the workspace timezone. The API returns the underlying timestamp, often in UTC. A row created at 12:35am in Kolkata is "today" in the Notion UI and "yesterday" in the raw timestamp. If you build an automation that pulls "records created today" by comparing a formatted date string, every row created in that early-morning window lands on the wrong side of the boundary. You miss some and double-count others, and only around midnight, which makes it look random.
The fix is to never treat a formatted formula date as a machine value. Read the actual timestamp, normalize it to a single timezone inside n8n, and compare there. Formatting is for display. Comparison logic belongs in the automation layer where you control the timezone explicitly.
Relations versus rollups for automation
It is worth separating relations from rollups, because they often get lumped together and they behave very differently for automation.
A relation is a real, writable link between rows. Your automation can read it, set it, and rely on it. Relations are part of the safe set. When you need to know which payments belong to an invoice, follow the relation and read the related rows directly. You get the actual records, with their real property values, that you can read precisely.
A rollup is a computed summary sitting on top of a relation. It is convenient for a human glancing at the page, but it is a derived value with all the drift and shape problems above. The pattern that holds up: keep the relation, drop your dependence on the rollup. Traverse the relation in n8n and aggregate the related rows yourself when you need a total or a count. You end up with a number you computed, at a known moment, from records you can see, instead of a number Notion computed at some other moment for reasons you cannot inspect.
The fix: compute in n8n, store plain properties
Every trap above has the same root and the same fix. The root is asking a formula or rollup to be the source of truth for a value your automation needs to read reliably or write at all. The fix is to move the computation out of Notion and into your automation layer, and to write the result back into a plain, writable property.
Concretely, instead of a "Status Label" formula that your automation reads, you keep a "Status" Select property that your automation writes. The logic that decides "Overdue" lives in an n8n Code node, runs at a known time, and sets a value Notion will not touch.
// The reliable pattern: compute in n8n, write a plain property.
// "Status" here is a Select (writable), not a formula.
// PATCH /v1/pages/{page_id}
{
"properties": {
"Status": { "select": { "name": "Overdue" } },
"Days Overdue": { "number": 4 }
}
}
// Now the automation OWNS the value. It can read it back exactly,
// branch on it, and dedupe on it — and nothing recomputes it
// behind your back between one run and the next.This flips the ownership. The automation now owns the value. It can read it back exactly as it wrote it, branch on it with a simple equality check, and use it as a stable deduplication key. Nothing recomputes it between runs. There is no nested shape to unwrap and no timezone surprise, because the value is whatever your code decided it was.
The objection I hear is that this means the value can go stale: a formula updates instantly, a written property only updates when the automation runs. That is true, and it is the right tradeoff for anything an automation depends on. A value that updates on a schedule you control is far safer than a value that updates on a schedule you cannot see. If you need it fresher, run the automation more often. You still keep control of when the number changes, which is the entire point.
You can keep formulas, too. There is nothing wrong with a formula that exists purely for a human to read on the page. The line is simple: if an automation reads it or needs to write it, it should not be a formula or a rollup.
A reference for which property types to trust
When I design a Notion database that an automation will touch, I sort every property into two buckets before writing a single workflow. One bucket is safe to read and write. The other is computed, read-only, and must never be a source of truth for automation logic.
The safe set covers almost everything you actually need: title, text, number, checkbox, select, multi-select, date, URL, email, phone, people, and relation. These you can read with confidence and write directly. The read-only set, formula, rollup, created time, created by, last edited time, and last edited by, are fine to display and fine to read occasionally, but they should never be the property your automation writes to or keys a decision on.
If you design with that split in mind from the start, almost none of the traps in this post can happen to you. The automation only ever writes to properties it is allowed to write to, and only ever depends on values it controls.