sequel-notion
A Sequel adapter for Notion. Each Notion
data source (the tables inside a Notion database, API version
2026-03-11) is a Sequel table: you read it with where, order,
limit and select, write it with insert, update and delete, and
can put a Sequel::Model on top of it.
Every Sequel call becomes one or more Notion API requests; nothing is
translated to SQL. What Notion can answer is sent to Notion. What it
cannot — joins, groups, aggregates, distinct, unions — is computed in
Ruby, only on a dataset that asks for it with client_side. Anything
else raises a Sequel::Error rather than be dropped.
- Requirements
- Installation
- Connecting
- Naming tables
- Reading
- Computed in Ruby
- Values
- Writing
- Models
- Schema
- Raw requests
- Errors, retries and logging
- Known shortfalls
- Checked against the live API
Requirements
- Ruby >= 3.4
- A Notion integration token, with the integration shared to the pages and databases it should see
Installation
gem install sequel-notionOr from a checkout:
bundle install
bundle exec rake testConnecting
require "sequel"
DB = Sequel.connect(adapter: :notion, token: ENV["NOTION_TOKEN"])| Option | Meaning |
|---|---|
token |
The integration token (required) |
auto_register |
Discover every data source on the first lookup |
faraday_adapter |
Faraday adapter (default Faraday.default_adapter) |
The test suite passes faraday_adapter: [:test, stubs] to answer for
Notion.
Naming tables
A table name is resolved to a data source id in this order:
- a name registered with
register_data_sourceorregister_all_data_sources; - a table name that is itself a data source id (32 hex digits, dashes optional, either case);
- with
auto_register: true, every data source the token can see, discovered once; - a search for a data source whose title normalises to the table name. The result is remembered; two matching data sources raise.
Titles normalise to lower snake case. Accents are dropped from Latin
letters only, and other scripts are kept as they are: "My Tasks" →
:my_tasks, "Électricité" → :electricite, "タスク" → :タスク. A
title with no letter or digit ("🚀") is registered under its id.
A data source in the trash is never named by discovery, a search or
register_all_data_sources, though data_sources still lists it with
in_trash: true.
DB.register_data_source(:tasks, "<data source id>")
DB.register_all_data_sources(database: "<database id>")
DB.register_all_data_sources { |title, id| "notion_#{title}" } # by search
DB.tables # => [:tasks, ...]
DB.data_sources(query: "Bills") # => [{id:, name:, parent_database_id:, ...}]A name already bound to a different data source raises an error rather than
being rebound, and register_all_data_sources then registers none of the
names it found. Discovery is more lenient: a name two data sources share
is left out of tables and raises when it is looked up, until
register_data_source picks one; the other names register as usual, and
discovery keeps a name already registered.
Reading
DB[:tasks].where(Status: "In Progress", Done: false)
.order(Sequel.desc(:Due))
.limit(25)
.allEach row has :id (the page id), :in_trash, and one key per property,
named as in Notion (:"Due Date" for a property with a space). A property
named id or in_trash would hide the page's own column, so a data
source that has one raises; rename the property in Notion. What each
type reads back as is under Values.
Filters
| Sequel | Notion filter |
|---|---|
where(P: v), exclude(P: v)
|
equals, does_not_equal
|
where(P: nil) |
is_empty; excluded: is_not_empty
|
where(Done: true) |
checkbox equals, or a formula's |
where(:Done) |
the same |
where(P: [a, b]) |
or of equals; nil is empty |
where(P: []) |
no page: no request is sent |
exclude(P: []) |
every page: no filter is sent |
<, <=, >, >=
|
numbers; dates: before, after, … |
Sequel.like(:P, "%x%") |
contains |
"x%", "%x", "x"
|
starts_with, ends_with, equals
|
Sequel.like(:P, "%") |
is_not_empty; NOT LIKE: is_empty
|
| multi-select, people, relation |
= as contains
|
| formula | nested by the value's class |
| rollup giving one value | nested under number or date
|
| unique ID | its number: 62 or "TK-62"
|
&, |, ~
|
and, or, and the inverse operator |
A rollup filters when its function gives one value (sum, count,
latest_date, …). nil filters work on formulas and rollups too.
Negations follow SQL, where != never matches NULL: Notion's
does_not_equal and does_not_contain match an empty property, so
exclude(Status: "Done"), NOT LIKE and NOT IN add is_not_empty
beside them, whatever the property's type (a checkbox or a unique ID is
never empty and needs none, so nil on one raises, alone or in a list).
Notion nests
and/or two levels deep at most: an and inside an and is merged
into it, a level too many is distributed ((a & b) | c becomes
(a | c) & (b | c), up to 32 clauses), and a filter still deeper raises.
Ordering
order maps to Notion sorts. Notion puts empty values last in both
directions, so nulls: :first raises and nulls: :last changes nothing.
Ordering by :id or :in_trash raises: they are the page's own columns,
not properties Notion can sort by. A checkbox sorts false first (checked
live), and so do rows computed in Ruby. Pages looked up by id are sorted
in Ruby, as a query's would be.
Pages by id, and selection
where(id: "…") or where(id: [...]) fetches those pages directly,
including pages in the trash (:in_trash says so). An id may be written
with or without dashes, in either case, and a repeated id gives one row.
Several id conditions intersect. A missing page, or one from another
data source, is no row. An id condition combined with any other
condition raises Sequel::Error.
select(:Name, Sequel.as(:Due, :due)) keeps only those keys, renamed by
the alias. Only plain, existing columns can be selected.
Paging
Requests are paginated automatically, 100 rows each. offset is
applied to the rows read, so the rows it skips are still fetched, and
count pages through the results; neither needs client_side.
paged_each follows Notion's cursor, as Sequel's cursor adapters do: it
needs no order, sends one request per rows_per_fetch rows (at most
100), and ignores :strategy.
Computed in Ruby
Notion computes no aggregate, distinct, group, join or combination of
queries. The adapter works them out in Ruby over every row the query
returns, at 100 rows per request and about 3 requests a second, so it
does so only on a dataset that asks for it; otherwise such a query
raises before sending anything:
DB[:tasks].sum(:Hours) # raises: needs client_side
DB[:tasks].client_side.sum(:Hours) # reads every task
DB[:tasks].client_side(max_requests: 20) # and at most 20 requests
.join(:projects, id: :Project).all
Task.client_side.group_and_count(:Status).allquery ──▸ needs Ruby? ── no ──▸ Notion: where → filter, order →
(join, group, sorts; offset and limit on the
distinct, sum, …) pages read
│ yes
▾
client_side? ── no ──▸ Sequel::Error, nothing sent
│ yes
▾
Notion: one query per table, with the where
│ conditions that test it; max_requests caps
│ the requests of all of them
▾
Ruby: match, group, aggregate or combine the rows,
then order, offset and limit
max_requests counts every request of one query that Notion answers
with a 200, both tables of a join and both sides of a union included; a
rate-limited attempt the adapter retries, or a request that fails (a
page gone), counts nothing. The query raises Sequel::Error before the
request past it. A query run on a row inside each is a query of its
own, with its own budget.
-
Aggregates.
sum,avg,min,maxandcount(:col)skipnils and givenilover no value. -
Distinct.
distinctdrops repeated rows beforeoffsetandlimit;distinct(:P)keeps the first row of each value, in the query's order. -
Groups.
group(:P)with the columns it groups andcount,sum,avg,minormax(group_and_count,select_group) keeps one running value per group;order,offsetandlimitthen apply to the groups, empty values last.havingfilters them, on an aggregate written out (having { count.function.* > 1 }) or on an output's name, with SQL's rules fornil. -
Combined queries.
union(with or withoutall:),intersectandexcept(withoutall:) combine the rows of two queries as SQL does: the result takes the first query's column names, the other's values matched by position, and queries selecting a different number of columns raise.order,offset,limitand aselectof columns apply to the result, and awhere,group,having,distinctor join added to it raises. -
Joins.
joinandleft_joinmatch rows on one equality, and a relation matches every page it lists, so a join follows it:DB[:tasks].client_side.join(:projects, id: :Project) .where(Sequel[:projects][:Budget] > 1000) .select(Sequel[:tasks][:Name].as(:task), Sequel[:projects][:Name].as(:project))
Each
wherecondition that tests one table runs in Notion, on that table's query. On a left join's joined table it still filters the joined rows, as SQL'sWHEREdoes: a row whose partners all fail it is dropped, and a row with no partner is kept only if an empty page passes it (where(Sequel[:projects][:Budget] => nil)); that table is queried twice, with and without the condition. A column both tables have must be qualified. Without aselect, a later table's column wins a shared name, as with SQL adapters.
Values
How each Notion type reads back, and what a write takes for it:
| Notion type | Reads as | A write takes |
|---|---|---|
| title, rich_text | plain text | anything (to_s) |
| number | Integer or Float |
Numeric, decimal String
|
| select, status | the option name | the option name |
| multi_select | an Array of names |
an Array of names, or one |
| date | ISO start, or a Range
|
Date, Time, Range, … |
| checkbox |
true / false
|
true / false
|
| url, email, phone | a String
|
to_s |
| relation | an Array of page ids |
page id(s) |
| people | an Array of user ids |
user id(s) |
| files |
Sequel::Notion::Files |
File, URL, or an Array
|
| formula | its result | read-only |
| rollup | its value | read-only |
| unique_id | as shown, "TK-62"
|
read-only |
| created/edited time | an ISO 8601 String
|
read-only |
| created/edited by | Notion's user object | read-only |
nil clears a property: to [] for text and lists, false for a
checkbox, null otherwise.
- Text is written in runs of 2000 characters as Notion counts them (an emoji is two).
-
Numbers given as a
Stringmust be decimal ("1e3", not"0x1A"or"1_000"), in writes and filters alike; a number other than Integer or Float is sent as a Float. -
Dates read back as the ISO 8601 start, or as a
Rangeof the two strings when the date has an end, which a write takes back as is. A write also takes an ISO 8601Stringor{start:, end:}. Notion's date ranges include their end, so an exclusive ranged1...d2ends on the day befored2; it must then end on aDate, and an exclusiveTimerange raises. A range needs a start:d1..leaves the end open, and..d2raises, as does aHashwith no:start. Notion keeps a time to the minute:10:20:30.123reads back as10:20:00(checked live). A date filter takes aDate,Time,DateTimeor ISO 8601 string, and raises on anything else. - Relations and people read back in full: a page lists at most 25, and the rest is fetched from Notion for the columns a query selects.
-
Files: an unnamed file is named after the URL's last path segment,
decoded (
a%20b.pdfnames ita b.pdf); a file of a type the adapter does not know reads back with itsrawhash and is written back unchanged. -
Rollups read as their value: a number, a date, or an
Arrayof the rolled-up values, each read as its own type.
NaN and Infinity, which JSON cannot carry, raise Sequel::Error in a
write or a filter, and so does an external file URL that does not parse
(a space in it, for instance).
Writing
id = DB[:tasks].insert(Name: "Write the adapter", Status: "Todo",
Tags: %w[ruby notion], Due: Date.today)
DB[:tasks].where(Status: "Todo").update(Status: "Done")
DB[:tasks].where(id: id).delete # moves the page to the trashValues are encoded according to the property's Notion type, read from
the data source (see Values). Writing a computed property
(formula, rollup, created/edited time or by, unique ID, button,
verification) or an unknown property raises Sequel::Error. insert
ignores :id; update ignores :id and turns :in_trash into
trashing or restoring the page, so where(id: id).update(in_trash: false)
restores a trashed page. A positional insert(["a", 2]) fills the
writable columns in schema order.
update and delete first collect the matching page ids, then send one
request per page.
Uploading files
DB.upload_file uploads bytes to Notion and returns the file upload's
id; File.uploaded turns that id into a value for a files property:
id = DB.upload_file(File.binread("q.pdf"), filename: "q.pdf",
content_type: "application/pdf")
DB[:leases].where(id: page).update(Docs: Sequel::Notion::File.uploaded(id))It takes the file's bytes, as a String or anything with #read (a
String is the content, never a path: upload_file("q.pdf", …)
uploads the five bytes q.pdf), and sends them in one part
(Notion's single_part mode; multi_part is not supported). The upload
reads back as a Notion-hosted file. Notion keeps the upload's file name
in ASCII only (reçu été "q".pdf became reu_t_q.pdf);
File.uploaded(id, name: "reçu.pdf") names the file as given. An upload
that is never attached carries an expiry_time an hour away. The id can also go into a file block
through raw requests.
Models
class Task < Sequel::Model(DB[:tasks])
end
task = Task.create(Name: "Ship it", Status: "Todo")
task.update(Status: "Done")
Task[task.id].deleteThe primary key is :id. A save of a loaded record sends only the
columns that changed, as update and save_changes do: a row reads
back rich text as plain text, and writing the whole row back would
make the loss permanent. Computed properties are marked generated in
the schema, for Sequel's skip_saving_columns plugin. Date columns are
not typecast, so a Time or a Range reaches Notion as given.
Notion cannot sort by page id, so a model adds no primary key order:
Task.paged_each streams in Notion's order, and Task.last needs an
explicit one, or raises Sequel's No order specified. A unique ID
property gives one in creation order, Task.order(:ID).last. A
created-time property does too, Task.order(:Created).last, but only to
the minute: Notion stores created and edited times without seconds, so
pages created in the same minute tie. Sequel's
paged_operations plugin pages by primary key ranges, so it raises on a
Notion model. Task.client_side opens what is
computed in Ruby to a model.
Notion has no transactions: DB.transaction runs its block, swallows
Sequel::Rollback (re-raised with rollback: :reraise), and rolls nothing
back. rollback: :always raises, since every write would be kept.
Schema
DB.schema(:tasks) lists :id, :in_trash and each property, with
:db_type set to its Notion type; computed properties are marked
generated: true. It is cached per data source. DB.table_exists?
answers by resolving the name and fetching the data source. Call
DB.refresh_schema!(:tasks) after changing the data source's properties
in Notion; every name of that data source (an alias, its id) sees the
change.
Raw requests
DB.request(verb, path, data = nil) sends one request to any JSON
endpoint the adapter does not cover (blocks, comments, users), through
the same connection, retries, errors and logging as every query:
DB.request(:get, "blocks/#{page}/children", page_size: 100)
DB.request(:patch, "blocks/#{page}/children", children: [block])
DB.request(:delete, "blocks/#{block_id}")-
pathis relative tohttps://api.notion.com/v1; the version sent isSequel::Notion::API_VERSION. -
datais the query string for:getand:delete, and the JSON body for:postand:patch. - It returns the parsed JSON body, a
Hash; a response that is not typed JSON comes back as itsString. - Errors and retries are those of the next section. Every POST but page creation and a file upload's send is retried, so a POST sent this way must be safe to repeat; so must a PATCH, retried after a timeout.
- It spends no
max_requestsbudget, unless called inside a query that set one. - It logs
VERB pathwithdata, sodatashould hold neither secrets nor bulk bytes.
It sends JSON only: a file goes through upload_file.
Errors, retries and logging
- A failed request, or a response typed JSON that does not parse, raises
Sequel::DatabaseError, with Notion's error code and message in the text. A 404 raises its subclassSequel::Notion::NotFoundError(where(id:)turns it into no row). - Responses 429, 502, 503 and 504 are retried up to four times, honouring
Retry-After. So are timeouts and failed connections (refused, reset, unresolved). Page creation and a file upload's send are retried only after a 429 or when the connection never opened, so a timeout or a reset cannot create a page twice, nor send a file Notion already holds (it refuses a second send, checked live). - Requests go to the database's loggers (
DB.loggers << Logger.new($stdout)) asPOST data_sources/…/querywith their body.
Known shortfalls
- No raw SQL (
with_sql,run,truncate, schema changes): there is no SQL to run it, and each raisesSequel::Error. No locks (for_update): Notion has none. - Joins are inner or left, on one equality; right, full and cross joins,
and a
wherecondition testing two tables, raise. - What is computed in Ruby reads every
row its queries return, and so do
offsetandcount. - Filters compare a property with a value, never with another property or an expression.
-
LIKEpatterns are limited to the shapes in the filter table. A_wildcard or a%in the middle raises. Notion's own case rules apply to bothLIKEandILIKE. On a multi-select, people or relation property, Notion matches whole values only, so a pattern with any%raises. - A rollup that keeps every value (
show_original,show_unique) cannot be filtered: Notion'sany/every/nonehave no SQL reading. - Mentions inside a title or rich text are cut at 25 by Notion, unflagged, and are not completed.
- Notion's rate limit is 3 requests per second on most plans, so a large
updateordeleteis slow. The retry on a 429 is tested against stubs only: bursts of 30 and 90 parallel queries drew no 429 from Notion (2026-10-09).
Checked against the live API
The suite stubs Notion, so it proves the payloads match what the adapter
believes Notion accepts. These were also checked against
api.notion.com (2026-10-09):
-
Filters: title, url and email through the
rich_textkey; created and edited times through thedatekey; negations excluding empty values, formula negations excluding empty results, a rollup negation guarded against an empty average,NOT INon dates and created times, and filters merged or distributed to two levels; unique ID filters and sorts; filters and sorts on a sum rollup and on alatest_daterollup;nilfilters on string and number formulas and on both rollups. -
Dates: a time written to a date property kept to the minute; an
open range (
d..) and{start:}both clearing a range's end. -
Checkboxes: sorted
falsefirst,truefirst descending. -
Rollups: a
show_originalrollup through 30 relations read in full, all 30 values, in a query and by id. -
Files: a Notion-hosted file read from a page and written back
unchanged, signed URL included, kept by Notion.
upload_filewith binary bytes and a non-ASCII name, attached throughFile.uploadedwith and withoutname:, read back and downloaded byte for byte; a second send to the same upload refused; an unsent upload'sexpiry_timean hour after its creation (2026-10-22). -
Pages: lookups by id with or without dashes; creation with the
data_source_idparent; writing, reading back and clearing every writable type; trashing and restoring; a relation of 26 pages read in full; a date range read back as aRangeand written back; rollups of a number, dates and titles read as values. -
Models: create, update and destroy; a
saveof a loaded record keeping a date range's end and its relations;orderon a created-time property, whose values Notion stores to the minute. -
Reading:
paged_eachfollowing the cursor with apage_sizebelow 100. -
Computed in Ruby: refused without
client_sideand no request sent; aggregates,distinct,DISTINCT ON,group,having,union,intersectandexceptover rows with and without values, and aselecton their result; inner and left joins through a relation, with awhereper side, a sum and a group over them; a left join'swhereon its joined table, equal to a value and empty;max_requestsstopping a join. -
Discovery: search keeps listing a trashed data source, flagged
in_trash.
License
MIT — see LICENSE.txt.