Instabooks delivers fast read-only PostgreSQL access to your QuickBooks Online data, unlocking ledger-backed analytics, agents and applications.
We support a subset of PostgreSQL syntax focused on analytical use cases. Below we describe the schema and syntax you can use in your queries.
There are several tables in the public
schema. Table names are given in
idiomatic snake_case. Each table and table
column also carries a description inside the database.
One row per QuickBooks transaction in your books. Note that, unlike in the QuickBooks Online API, line-level information (i.e. amounts, account, etc.) is not provided directly in the transaction table. You must join with transaction_lines for that information.
| Column | Type | Description |
|---|---|---|
type | text | The kind of transaction, as QuickBooks names it, such as Invoice. |
oid | text | The QuickBooks id, unique within its type (not unique across the table). |
date | date | The date the transaction is posted under (not when it was entered). |
memo | text | The memo on the transaction as a whole. Note that lines can also carry their own memos. Different types of transactions may put useful information in one or the other. |
doc_num | text | The transaction's "document number," such as the invoice number for an invoice. This is an arbitrary user-entered string, not guaranteed to be numeric, or unique. |
created_at | timestamp | When QuickBooks first created this transaction. This is not the transaction post date. |
modified_at | timestamp | When this transaction was last changed in QuickBooks. |
One row per line of a QuickBooks transaction. When joining
with transactions, join on both
transaction_type
and transaction_oid.
| Column | Type | Description |
|---|---|---|
id | text | Unique string identifier for this line. |
transaction_type | text | The type of the transaction this line belongs to; joins to transactions.type. |
transaction_oid | text | The oid of the transaction this line belongs to; joins to transactions.oid. |
date | date | The parent transaction's date, copied here so a query can filter without joining. |
amount | numeric | The signed amount this line posts, as an exact decimal. See the double-entry note above. |
home_amount | numeric | The same amount in the company's own currency, for multi-currency books. |
memo | text | The memo on this line. |
combined_memo | text | All relevant memos for this line joined together. |
account_oid | text | The account this line posts to; joins to accounts.oid. Filtering on the joined account is what picks one side of a double-entry transaction. |
offset_account_oid | text | For two-line transactions, the account on the other side of the transaction; joins to accounts.oid. |
offset_account_fqn | text | The offsetting account's fully-qualified name, for convenience. |
class_oid | text | The class this line is tagged with, if any; joins to classes.oid. |
customer_oid | text | The customer this line is attributed to, if any; joins to customers.oid. |
department_oid | text | The department this line is tagged with, if any; joins to departments.oid. |
vendor_oid | text | The vendor this line is attributed to, if any; joins to vendors.oid. |
The chart of accounts, as a tree. Each account may have a parent. To simplify matching all subaccounts under a parent account, the fully-qualified
name of an account is given in the fqn column.
| Column | Type | Description |
|---|---|---|
oid | text | The QuickBooks id, unique among accounts; what the account_oid columns refer to. |
name | text | The account's name. |
num | text | The account number, as text, so that leading zeroes are significant. |
fqn | text | The account's "fully-qualified name," which is a path joined with colons, such as Expenses:Travel. A subtree can be queried using a LIKE prefix match. |
type | text | The account's type, as QuickBooks calls it. |
subtype | text | The narrower QuickBooks classification under type, also known as "Detail Type" in QuickBooks. |
class | text | The account's class, which is one of: "asset", "liability", "equity", "income", or "expense". Unrelated to the classes table (transaction classes). |
currency | text | The currency this account is denominated in. |
description | text | The description entered on the account in QuickBooks. |
parent_oid | text | The account this one sits under, or null at the top of the tree. |
active | bool | Whether the account is still in use. |
Transaction classes, which are tags used to classify transactions or lines.
(Whether the class can vary per line within a transaction depends on your QuickBooks settings.)
Like accounts, they form a hierarchical tree, and fqn contains the full path.
| Column | Type | Description |
|---|---|---|
oid | text | The QuickBooks id, unique among classes; what transaction_lines.class_oid refers to. |
name | text | The class's name. |
fqn | text | The full path from the top of the tree, joined with colons. |
parent_oid | text | The class this one sits under, or null at the top of the tree. |
active | bool | Whether the class is still in use. |
Another tag for categorizing transactions, called locations in some books. (It is possible for this value to differ between lines of a transaction, for example with location-tracked journal entries in QuickBooks.) Like accounts and classes, departments form a hierarchical tree.
| Column | Type | Description |
|---|---|---|
oid | text | The QuickBooks id, unique among departments; what transaction_lines.department_oid refers to. |
name | text | The department's name. |
fqn | text | The full path from the top of the tree, joined with colons. |
parent_oid | text | The department this one sits under, or null at the top of the tree. |
active | bool | Whether the department is still in use. |
Who a line is attributed to on the income side. Join it from
transaction_lines.customer_oid.
| Column | Type | Description |
|---|---|---|
oid | text | The QuickBooks id; what transaction_lines.customer_oid refers to. |
display_name | text | The name shown for this customer in QuickBooks. |
active | bool | Whether the customer is still in use. |
Who a line is attributed to on the expense side. Join it from
transaction_lines.vendor_oid.
| Column | Type | Description |
|---|---|---|
oid | text | The QuickBooks id; what transaction_lines.vendor_oid refers to. |
display_name | text | The name shown for this vendor in QuickBooks. |
company_name | text | The vendor's company name, where it differs from the display name. |
active | bool | Whether the vendor is still in use. |
A one-row table holding a plain-text description of everything on
this page, so a client — or an LLM writing SQL for you — can learn
the dialect without leaving the database:
select guide from readme;
One SELECT per statement. Within that:
*, columns, aliases,
literals, arithmetic (+ - * /), and
|| string concatenation.
INNER, LEFT, RIGHT,
FULL, CROSS.
=, <>,
<, >, <=,
>=, AND/OR/NOT,
IS [NOT] NULL, IS TRUE/FALSE.
where type in ('Invoice', 'Bill').
x::int, x::date, x::text).
ASC/DESC
and NULLS FIRST/LAST.
LIMIT,
OFFSET, and UNION /
UNION ALL.
$1, $2, …
bound by your driver. Types are inferred.
Aggregates: count (including
count(*) and count(distinct x)),
sum, avg, min,
max, and
bool_and/bool_or/every.
Scalar: coalesce,
nullif, lower, upper,
length, substr, abs, and
date_trunc.
date_trunc('year'|'quarter'|'month'|'week'|'day', date)
buckets server-side and can be a GROUP BY or
ORDER BY key — so a monthly or yearly trend is one
grouped query, not one query per period.
Filter dates by comparing to literals:
where date >= '2023-01-01' and date <= '2023-12-31'.
A timestamp column (created_at,
modified_at) has to be cast to a date first, and that
cast really converts:
date_trunc('month', created_at::date).
accounts.fqn is a colon-joined path, so a whole subtree
is a prefix match — and it works inside a join, with no round trip
to collect ids first:
select a.type, sum(l.amount)
from transaction_lines l
join accounts a on l.account_oid = a.oid
where a.fqn like 'Expenses:%'
group by a.type;
Attemping to use the following syntax returns a PostgreSQL error. The error can in some cases include useful information about alternatives.
COPY. The connection is read-only.
FROM and IN (SELECT …). Use a join, a
literal value list, or two queries.
OVER),
INTERSECT, and EXCEPT.
BETWEEN (write two comparisons) and
ILIKE (write
lower(x) like lower(…)).
extract(…) — use date_trunc. Also
now(), round(), and most other scalar
functions.
stddev,
variance, string_agg,
array_agg, the percentile family.
DECLARE/FETCH),
PREPARE, and more than one statement per message.
Transaction control (BEGIN, COMMIT,
ROLLBACK) and SET are accepted and do
nothing.
active columns come back as
t/f, and dates as
YYYY-MM-DD.
LIKE is case-sensitive, as in
PostgreSQL, so 'expenses:%' matches nothing.
-- Look before you choose: what account types exist?
select type, count(*)
from accounts
group by type
order by type;
-- Monthly revenue.
select date_trunc('month', l.date) as month,
sum(l.amount) as revenue
from transaction_lines l
join accounts a on l.account_oid = a.oid
where a.type = 'Income'
group by date_trunc('month', l.date)
order by month;
-- Expense by account, for one year.
select a.fqn, sum(l.amount) as amount
from transaction_lines l
join accounts a on l.account_oid = a.oid
where a.type = 'Expense'
and l.date >= '2024-01-01' and l.date <= '2024-12-31'
group by a.fqn
order by sum(l.amount) desc;
-- Expense by class, quarter over quarter.
select date_trunc('quarter', l.date) as quarter,
coalesce(c.fqn, '(unclassified)') as class,
sum(l.amount) as amount
from transaction_lines l
join accounts a on l.account_oid = a.oid
left join classes c on l.class_oid = c.oid
where a.type = 'Expense'
group by date_trunc('quarter', l.date), coalesce(c.fqn, '(unclassified)')
order by quarter, class;
-- Find the transactions behind a number.
select t.date, t.type, t.doc_num, l.combined_memo, l.amount
from transaction_lines l
join transactions t
on t.type = l.transaction_type and t.oid = l.transaction_oid
join accounts a on l.account_oid = a.oid
where a.fqn like 'Expenses:Travel%'
order by t.date desc
limit 50;
You can use the information_schema table to explore the schema via a SQL query.
select table_name from information_schema.tables;
select column_name, data_type
from information_schema.columns
where table_name = 'transactions'
order by ordinal_position;