Skip to content
notionsolutions

A practical library for building,
organizing and working in Notion.

Formula guides

Notion map() and filter() Formulas: Build the Filtered Rollup

map() turns each related page into a value and filter() keeps only the pages that pass a rule: the filtered rollup Notion's rollup menu doesn't offer. How current and index work, find(), some() and every(), nine recipes on one project, nested current, automations and the errors long chains hit.

Published
Seven white ceramic tiles on a steel rail: two set aside, one passing an electric-blue glass gate, and beyond it four frosted-glass tags standing in a row on steel stems
filter() picks the related pages that pass a rule, and map() turns what's left into the values you need.

In a Notion formula, map() turns each item in a list into something else, and filter() keeps only the items that pass a condition. A Relation property is a list of pages, so prop("Tasks").filter(current.prop("Status") != "Done") returns a project's open tasks, and adding .map(current.prop("Due")) turns them into their due dates. That's the filtered rollup people go looking for: a rollup works on every related page, while the formula works only on the pages that match. I'd write filter() first and map() second, because the condition can still read any property on the page, and you pull out the one value you need after the list is narrowed.

For a plain total of every related page, such as all minutes logged on a project, keep a rollup. For a question with "only" in it, such as open tasks, unbilled hours or the next due date among unfinished work, use a formula with filter(). For one page or a yes/no answer, use find(), some() or every(). The basic one-line relation patterns are listed in the guide to prop() and current.prop(); this guide picks up where those stop, with chains, conditions on several properties and the cases where a rollup is still the better property.

How current and index work inside a list function

A list function runs its expression once per item. Inside that expression, current is the item being processed and index is its position, counted from zero. Notion Mastery's overview of everything new in Formulas 2.0 describes them as "the provided values current and index" that list functions such as map hand to their expression. Notion's formula syntax reference shows both in one example:

map([1, 2, 3], current + index)

The result is [1, 3, 5]: 1 + 0, 2 + 1 and 3 + 2. Thomas Frank's map() reference isolates the position with [1, 2, 3].map(index), which returns [0, 1, 2], because "the first element has an index of 0."

Three things follow once the list is a relation:

Order matters once the condition and the output are different properties. Notion's guide to what changed in the new formulas counts completed tasks with prop("Tasks").map(current.prop("Status")).filter(current == "Done").length(), which works because it filters the same Status values it just mapped. To get the due dates of open tasks, map() can't come first: after .map(current.prop("Due")) the list holds dates, and a date has no Status left to check. Filter the pages, then map. For the wider picture of what 2.0 changed, the Formulas 2.0 guide covers it.

Which list function answers which question

Six functions do most of the work on a relation. Each answers a different question and returns something different when items fail the condition. The examples are the ones in Notion's syntax reference.

map, filter, find, findIndex, some and every side by side
FunctionReturnsQuestion it answersNotion's exampleWhen items fail the condition
map()A new list, one result per itemWhat is this value for each related page?map([1, 2, 3], current + 1) = [2, 3, 4]No test: every item is transformed
filter()A list of the items that passWhich pages meet the rule?filter([1, 2, 3], current > 1) = [2, 3]Those items are left out of the new list
find()The first item that passesWhich page comes first that meets the rule?find(["a", "b", "c"], current == "b") = "b"Empty, as in find([1, 2, 3], current > 100)
findIndex()The zero-based position of the first matchWhere in the list is that page?findIndex(["a", "b", "c"], current == "b") = 1-1 when nothing matches
some()true or falseDoes any page meet the rule?some([1, 2, 3], current == 2) = truefalse when no item passes
every()true or falseDo all pages meet the rule?every([1, 2, 3], current > 0) = truefalse when any item fails: every(["a", "b", "c"], current == "b") = false

After one of these, a helper usually finishes the chain. Each has a one-line example in the same reference:

The filtered rollup, rebuilt as a formula

A rollup asks you for the relation, the property on the related pages, the calculation and the number formatting, according to Notion's relations and rollups help page. The calculation menu runs from Show original and Show unique values through the Count and Percent options, then Sum, Average, Median, Min, Max and Range for numbers and Earliest date, Latest date and Date range for dates. None of those calculations takes a condition. Each one works on all related pages, so no rollup setting gives you "minutes on unbilled sessions" or "the earliest due date among open tasks". A formula fills that gap.

Two different things get called a rollup filter. Filtering a view by a rollup column decides which rows the view shows, and each row's rollup still covers all of its related pages. Changing what the rollup itself includes takes filter() in a formula.

Without filter(), getting there took real upkeep. David Ntumba's Medium walkthrough of filtered rollups, from July 2023, related each matching sale to a summary record, used buttons to make those links, and concluded that "the filtering was done manually." With filter(), the condition lives inside the formula, so there are no extra links to keep current.

Alex, who writes as aNotioneer, shows in a guide to recreating every rollup type with formulas that each calculation has a formula twin. Show original is map(prop("Tasks"), current.prop("Name")), Sum ends in .sum(), and Earliest date ends in .sort() .first(). Once a rollup is written as a formula, the condition is one more step at the front of the chain:

Rollup calculations and their versions with a condition
Rollup calculationFormula that adds a condition
Show originalprop("Tasks").filter(current.prop("Status") != "Done")
Show unique valuesThe same filter, then .map(current.prop("Phase")).unique()
Sum, Min, Maxprop("Sessions").filter(not current.prop("Billed")).map(current.prop("Minutes")).sum(), or end with .min() or .max()
Average, Medianmean(prop("Sessions").filter(not current.prop("Billed")).map(current.prop("Minutes"))), or median(…) around the same list
Earliest date, Latest dateFilter, then .map(current.prop("Due")).sort().first(), or .last()
Count allFilter, then .length()

When I'd keep the plain rollup

I'd keep a rollup in two cases. The first is when the whole related set is the answer. Total minutes across every session come out the same either way, and a rollup shows its relation, property and calculation as settings that whoever maintains the database can check without reading formula code. The second is when the subset is a person's choice rather than a rule. If you pick by hand which sessions go on an invoice by relating them to an Invoice page, a Sum rollup on that relation is already filtered by the person who chose them. A formula is the better tool when the rule can be written down, like "Billed is unchecked" or "Status isn't Done".

A formula also goes where a rollup can't. Notion's help page answers "Can I rollup a rollup?" with "Unfortunately not". A formula on a Clients database can read two relations deep instead: prop("Projects").map(current.prop("Tasks")).flat() returns every task across the client's projects as one list, ready for filter(). A task related to two of those projects appears once for each, and the length() counting guide deals with that when you count across two relations. If the relation itself isn't set up yet, start with the relations and rollups setup guide. If a plain Sum rollup already disagrees with the linked records, the rollup sum guide reconciles it before you add a condition on top.

Recipes for a Projects and Tasks setup

Every recipe runs on one sample project, Brand guide, in a Projects database with two relations: Tasks and Sessions. Name is the tasks' title, Status is a Status property, Approval and Phase are selects, and Due is a date without a time. Assume today() returns November 12 and every date falls in the same year. Each recipe gives the question, the formula and what it returns for this sample; the test results section lays all of them next to what a rollup would show.

The seven tasks related to Brand guide, in relation order
TaskStatusDueApprovalPhase
Discovery briefDoneNov 3ApprovedStrategy
Usage rulesTo DoNov 17EmptyCopy
Color paletteDoneNov 10WaitingDesign
Type pairingsDoingNov 10EmptyDesign
Final PDFTo DoNov 21EmptyProduction
Voice notesTo DoNov 14EmptyCopy
Client sign-offTo DoEmptyEmptyProduction

1. Which tasks are still open?

Filter on Status, then turn the remaining pages into one line of names:

prop("Tasks")
  .filter(current.prop("Status") != "Done")
  .map(current.prop("Name"))
  .join(", ")

Returns "Usage rules, Type pairings, Final PDF, Voice notes, Client sign-off". Stop after the filter() line and the formula returns the five pages themselves; Notion's guide to the new formulas says formulas now display pages, dates, people and lists as well as text, numbers and checkboxes. End with .length() instead of map() and join() and it returns 5. Notion's syntax reference writes the same condition as prop("Tasks").filter(current.prop("Status") !== "Done"); its unequal() entry uses !=, which is the spelling these recipes keep.

2. A numbered list of open tasks

prop("Tasks")
  .filter(current.prop("Status") != "Done")
  .map(format(index + 1) + ". " + current.prop("Name"))
  .join(", ")

Returns "1. Usage rules, 2. Type pairings, 3. Final PDF, 4. Voice notes, 5. Client sign-off". Final PDF is the fifth page in the relation but number 3 here, because map() receives the filtered list and index counts positions in that list. format() turns the number into text, as in Notion's format(1234) = "1234", so it can be joined to the name with +. Without it you're joining a number to text, which Notion's writing guide names as a cause of a "Type mismatch" error.

3. Done but not approved

A condition can test more than one property of the same page. This one finds work that's finished but still waiting on the client:

prop("Tasks")
  .filter(current.prop("Status") == "Done" and current.prop("Approval") != "Approved")
  .map(current.prop("Name"))
  .join(", ")

Returns "Color palette". Discovery brief is Done too, but it's already Approved. Notion's reference lists and, && and and(true, false) as forms of the same operator, so pick one spelling and stay with it. A task with an empty Approval would also pass the second test, because an empty value isn't equal to "Approved". That's the behavior you want here: no approval recorded means not approved.

4. The next due date among open tasks

prop("Tasks")
  .filter(current.prop("Status") != "Done" and !empty(current.prop("Due")))
  .map(current.prop("Due"))
  .sort()
  .first()

Step by step: filter() keeps four open tasks with a date (Client sign-off has none), map() gives [Nov 17, Nov 10, Nov 21, Nov 14], sort() orders them as [Nov 10, Nov 14, Nov 17, Nov 21], and first() returns Nov 10. Notion's reference shows sort() only with numbers. Sorting dates and taking the first one is the pattern Matthias Frank uses in his Notion Formulas 2.0 tutorial, and aNotioneer recreates the Earliest date rollup the same way. The !empty() check keeps undated tasks out of the sort, so the answer never depends on where an empty date would land. An Earliest date rollup on Due returns Nov 3, Discovery brief's date, because it can't skip finished work. When related dates come back as text or the wrong type, the date formula errors guide resolves them with an explicit rule.

Chain diagram for one project's tasks: seven related task pages narrowed by filter() to four open tasks with a due date, map() turning them into Nov 17, Nov 10, Nov 21 and Nov 14, sort() ordering them and first() returning Nov 10
Filter the pages first, while they still carry Status, then map them to dates and pick the earliest.

5. The next task itself, with find()

A date tells you when, not what. To return the page, store the date with let() and look for the open task that carries it:

let(
  nextDue,
  prop("Tasks")
    .filter(current.prop("Status") != "Done" and !empty(current.prop("Due")))
    .map(current.prop("Due"))
    .sort()
    .first(),
  prop("Tasks").find(current.prop("Status") != "Done" and current.prop("Due") == nextDue)
)

Returns the Type pairings page. This is the shape of Matthias Frank's next-task formula with one change. His find() matches on the date alone, and find() returns the first item that passes. In the Brand guide sample, Color palette is also due Nov 10 and sits earlier in the relation, so a date-only match would return a task that's already Done. The Status check belongs in both places. If no task is open, nothing passes and find() returns Empty, as in Notion's find([1, 2, 3], current > 100) example. Swap find() for findIndex() with the same condition and you get 3, Type pairings' zero-based position in the relation, or -1 when nothing matches.

6. Which phases still have open work?

prop("Tasks")
  .filter(current.prop("Status") != "Done")
  .map(current.prop("Phase"))
  .unique()

map() returns [Copy, Design, Production, Copy, Production], and unique() drops the repeats, leaving Copy, Design and Production. A Show unique values rollup on Phase lists four phases, Strategy included, because Discovery brief is still part of the related set even though it's done.

7. Is anything overdue?

prop("Tasks").some(
  !empty(current.prop("Due")) and
  current.prop("Due") < today() and
  current.prop("Status") != "Done"
)

Returns true: Type pairings was due Nov 10 and is still Doing. Color palette shares the date but is Done, so it doesn't count. Dates compare with < and > the same way the property table compares prop("Due Date") > now(), and Notion's reference says today() "Returns the current date without the time", so with date-only Due values a task due today isn't overdue yet, since < is strict. Frank's some() page points out that once a true/false value sits in a formula property, you can filter a database view by it; here, that gives a view of only the projects with something late. For the number of late tasks rather than a yes or no, replace some() with filter() and finish with length().

8. Is every deliverable approved?

if(
  prop("Tasks").length() > 0,
  prop("Tasks").every(current.prop("Approval") == "Approved"),
  false
)

Returns false, since only Discovery brief is Approved. The guard copies the shape of Notion's task automation in its formulas help article, which checks that a parent task has sub-items before running every() and returns false when it has none. Notion writes it with the ternary operator, which its reference treats as the same thing as if(). A project with no tasks shouldn't show up as fully approved, so the empty case gets an explicit answer instead of whatever every() would make of an empty list.

9. How many unbilled hours?

Frank's map() page totals the time on a task across many work sessions with prop("Sessions").map(current.prop("Duration")).sum(), sample output 225. Put a filter on a Billed checkbox in front and the same chain shows what hasn't been invoiced. Brand guide's Sessions relation:

The five sessions related to Brand guide
SessionMinutesBilled
Kickoff call90Checked
Logo concepts120Checked
Palette revisions50Unchecked
Font tests75Unchecked
Review prep40Unchecked
prop("Sessions")
  .filter(not current.prop("Billed"))
  .map(current.prop("Minutes"))
  .sum() / 60

filter() keeps Palette revisions, Font tests and Review prep, map() gives [50, 75, 40], sum() returns 165, and dividing by 60 gives 2.75 hours. A Sum rollup on Minutes shows 375, which includes the 210 minutes already billed. not on a checkbox comes from Notion's property table, which lists not prop("Checkbox"); Matthias Frank writes the same kind of test as == false. The formula only reads the checkbox. Deciding when a session counts as billed, and ticking it when the invoice goes out, is still your job.

Two paths over the same five work sessions: a Sum rollup on Minutes returns 375, while filter() on unchecked Billed keeps 50, 75 and 40, sum() returns 165 and dividing by 60 gives 2.75 hours
The rollup adds every session; the formula adds only the ones not yet billed.

When a nested list function needs the outer page

Put one list function inside another and current changes meaning. Say a Clients database relates to Projects, each project has a Deadline date and a Tasks relation, and you want the client's projects where any task is due after the project's deadline. Inside the inner some(), current is a task, so the project needs a name of its own:

prop("Projects").filter(let(
  project, current,
  project.prop("Tasks").some(
    !empty(current.prop("Due")) and
    current.prop("Due") > project.prop("Deadline")
  )
))

let() stores the outer current as project before some() reuses the word for each task. The let() and lets() guide walks through the same move with a list of lists, along with the scope rules that decide where a variable can be read. If you only need the inner pages and not which project they came from, map() and flat() give you one list and no variable is needed.

map() and filter() in automations and buttons

The same functions work in database automations and buttons, with stricter rules. Notion's page on common formula errors says formulas in automations "expect a specific return type", and that relation, rollup and person properties return a list of pages or people. To use a property inside the relation, take the first element with .first() or .at(0), or loop through the pages with .map(), .filter(), .every(), .some() or .find(); otherwise the automation pauses. In practice, end the chain in the type the field expects:

Notion's formulas help article builds exactly this kind of step. Its task automation defines a Parent task variable with Trigger page.Parent item.first(), then an All subtasks done? variable with Parent Task.Sub-item ? Parent Task.Sub-item.every(current.Status == "Done") : false. They sit in separate actions for a reason: the errors page says variables defined within the same action "cannot reference each other". Two more rules from that page matter for long chains. Formulas need access to every page and database they reference, and an unexpected empty value, most often a date or a person, makes the automation pause. Its fix for a date is to guard it with if(Date, Date.dateAdd(1, "day"), empty()) instead of calling Date.dateAdd(1, "day") directly. To choose between the two triggers, see buttons vs. database automations; when a rule stops running, the trigger, action and exception guide starts with the event.

Limits and errors in long chains

What goes wrong with list formulas on relations, and the fix
What you seeCauseFix
A list where you expected one valueA relation is always a list of pages, even with one page in itFinish with first(), at(0), find() or a reducer such as sum()
"Type mismatch"Numbers joined to text, or mixed types in one resultConvert with format() before joining
A Multi-select condition never matchesA Multi-select is a list of text, not one valueUse includes(): current.prop("Tags").includes("Finance"), after Notion's prop("Tags").includes("Finance")
find() shows Empty, findIndex() shows -1No item passed the conditionCheck the condition against one page, including the exact option name
A formula depth errorNotion allows 15 layers, and every reference to another formula or rollup adds one, even in a different databaseRead source properties with current.prop() instead of through helper rollups, and merge helper formulas
A rollup the formula reads is blankThe rollup, not the formulaWork through the rollup not working checklist first
The same page twice after flat()It's related to more than one of the outer pagesExpected; decide whether it should count once or once per project
An automation pausesA list or an empty value where the field expects one valuefirst(), at(0) or a loop function, plus an if(…, …, empty()) guard

Build a long chain one step at a time. Notion's guide to writing database formulas suggests you "test your formula in smaller bits to make sure you're getting the desired output at each step", and the formula editor's live preview, which Notion Mastery describes as a real-time result for the page you opened the editor from, shows what each step returns. I'd start on one project small enough to check by eye: stop after filter(), confirm the pages, then add map() and the helper.

Test results: documented outputs and one project worked through

The first table collects the outputs Notion and Thomas Frank publish for each function, so you can confirm the behavior before building on it. Both define find() as the first matching item, some() as at least one match and every() as all items matching, and where their examples cover the same function they agree.

Documented examples and their outputs
ExampleOutputPublished by
map([1, 2, 3], current + index)[1, 3, 5]Notion syntax reference
[1, 2, 3].map(index)[0, 1, 2]Frank, map()
[1, 2, 3].map(current * index)[0, 2, 6]Frank, map()
[1, 2, -3, 0, 5].filter(current > 0)[1, 2, 5]Frank, filter()
filter([1, 2, 3], current > 1)[2, 3]Notion syntax reference
find([1, 2, 3], current > 100)EmptyNotion syntax reference
[1, "Monkey D. Luffy", true, "2025-01-01", 37, "1999-12-23"].find(current.parseDate())"2025-01-01"Frank, find()
findIndex([1, 2, 3], current > 100)-1Notion syntax reference
[1, 0, 3, -12, 5].some(current > 0)trueFrank, some()
some(["a", "b", "c"], current.length > 2)falseNotion syntax reference
["1999-04-01", "2023-01-25", "2025-13-32"].every(current.parseDate())falseFrank, every()
unique([1, 1, 2]), sort([3, 1, 2])[1, 2] and [1, 2, 3]Notion syntax reference
prop("Sessions").map(current.prop("Duration")).sum()225 (sample output)Frank, map()
prop("Tasks").filter(current.prop("Due").day() > 5).length()2 (sample output: tasks due on a weekend)Frank, filter()

The last two rows are sample outputs from Frank's example databases, so they show the shape of a result rather than a number to expect in yours. The second table applies every recipe to the Brand guide sample, with the steps that produce each result and what the closest rollup calculation returns for the same project.

Brand guide: each formula result next to the closest rollup
QuestionFormula resultHow it gets thereClosest rollup gives
How many open tasks?5Every task except Discovery brief and Color paletteCount all: 7
Open task namesUsage rules, Type pairings, Final PDF, Voice notes, Client sign-offThe same five, in relation orderShow original: all 7 pages
Numbered open tasks1. Usage rules … 5. Client sign-offindex counts in the filtered listNo matching calculation
Done but not approvedColor paletteDiscovery brief is Done and ApprovedNo matching calculation
Next due date among open tasksNov 10[Nov 17, Nov 10, Nov 21, Nov 14] sorted; Client sign-off has no dateEarliest date: Nov 3
Next open taskType pairingsColor palette is also due Nov 10 but DoneNo matching calculation; a date-only find() returns Color palette
Its position3Zero-based position in the relationNo matching calculation
Phases with open workCopy, Design, ProductionFive values, two repeats removedShow unique values: Strategy, Copy, Design, Production
Anything overdue?trueType pairings, due Nov 10, still DoingNo matching calculation
Every task approved?false1 of 7 tasks is ApprovedNo matching calculation
All session minutes37590 + 120 + 50 + 75 + 40Sum: 375
Unbilled minutes165, or 2.75 hours50 + 75 + 40Sum: 375
Average unbilled session55 minutes165 divided by 3 sessionsAverage: 75

Of the thirteen questions, the rollup menu answers one as asked: total session minutes, where Sum and the formula both give 375. For the other twelve, the closest rollup either takes in finished or billed work or has no calculation for the question at all. That's the case for filter() in one table.

Verdict: filter first, then pick the value

Keep a rollup for the plain whole-set number people read on its own, like total minutes on a project. As soon as the question has "only" in it, write a formula: filter() for the rule, map() for the property you want, then sort().first(), sum(), join() or length() to finish. Use find() when you need the page itself, and give it the same condition you used to pick the date. Use some() and every() for yes/no columns, with a guard for projects that have no tasks yet. The formula applies the rule; you still decide what the rule is, which Status counts as open and when a session becomes billed. Start by rewriting the one rollup you've been wishing had a filter.

Questions that come up next

Can map() and filter() work on a rollup instead of the relation?

Only when the rollup returns a list. Notion's syntax reference gives a rollup's formula type as "Number, date, or list of any type. Depends on rollup configuration." A Show original rollup shows the related pages, the same as the relation itself, while a Sum rollup is a single number that list functions can't loop over. I'd read the relation directly anyway: it already has every property you need, and each extra rollup in the path adds a layer toward the 15-layer limit.

Is map() the same thing as Notion's Map view?

No. map() is a formula function that transforms a list. Map view is a database layout: Notion's help article on map view describes it as a way to "visualize database items on an interactive map", with each location coming from a Place property.

Work with the related list