Research notes from reverse-engineering the Apple Notes storage format.
Notes live in a Core Data-backed SQLite database:
~/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite
Key tables:
| Table | Purpose |
|---|---|
ZICCLOUDSYNCINGOBJECT |
Polymorphic table for all entities (ICNote, ICFolder, ICAttachment, etc.) |
ZICNOTEDATA |
Note content blobs. ZNOTE FK to the note's Z_PK |
ZICCLOUDSTATE |
Per-object sync version tracking |
ATRANSACTION |
Core Data persistent history transactions |
ACHANGE |
Per-entity change records within a transaction |
Z_PRIMARYKEY |
Entity metadata + auto-increment max values |
Each note's content is stored as a gzip-compressed protobuf blob in ZICNOTEDATA.ZDATA. The protobuf contains:
- text — full plain text of the note (Unicode,
\ufffcfor attachment placeholders) - crdtEntries — character-level CRDT edit history
- metadata — UUID + per-replica counters
- attributeRuns — styling spans (bold, headings, checklists, links, etc.)
See notestore.proto for the full schema and proto.ts for the codec.
Each note contains a list of CRDT entries tracking character insertions and deletions:
CRDTEntry {
start: { replicaId, counter } // allocation position
length: uint32 // number of characters
end: { replicaId, counter } // parent/predecessor reference
flag: int32 // 1 = tombstone (deleted text)
sequence: uint32 // 1-based ordering
}
The last entry is always a sentinel: start={0, 4294967295}, length=0, end={0, 4294967295}.
- The text field is the source of truth for display, but the CRDT entries are the source of truth for sync. If you change the text without updating CRDT entries, the next real edit through Notes.app will revert your change.
- Apple compacts the CRDT log on every save. Entries get reordered by document position (not creation time), and can be split when text is inserted in the middle. This is NOT a pure append-only log.
- Metadata counters are
[next_start_counter, next_end_counter]for replica 1. When adding entries, bump the start counter by the number of characters inserted. - The
endfield's exact semantics are not fully decoded. For batch inserts (multiple chars typed at once),endis{1, 0}. For single-char sequential typing,endcounters increment. - Entries with
flag=1are tombstones (deleted characters). They still count toward counter allocation but not toward the visible text length.
Styling is stored as a list of attribute runs that tile the text. Each run specifies:
- length — number of characters covered
- paragraphStyle — style type (0=Title, 1=Heading, 2=Subheading, 4=Mono, 100=Bullet, 101=Dash, 102=Number, 103=Checkbox), paragraph UUID, indent, block quote
- fontWeight — 1=bold, 3=bold+italic(?)
- underlined, strikethrough, superscript — integer flags
- link — URL string (
https://...orapplenotes:note/...) - attachmentInfo —
{ id, type }for inline attachments - timestamp — seconds since Unix epoch, present on body text runs
The sum of all attribute run lengths must equal the text length.
Tested and confirmed working with iCloud sync. The full procedure:
- Update the text field
- Add/modify CRDT entries (new entry before sentinel, bump sequence)
- Update metadata counters (
next_start_counter += chars_added) - Add/update attribute runs to cover the new text length
UPDATE ZICNOTEDATA SET ZDATA = <gzipped_protobuf>, Z_OPT = Z_OPT + 1
WHERE Z_PK = <notedata_pk>;UPDATE ZICCLOUDSYNCINGOBJECT
SET ZMODIFICATIONDATE1 = <apple_epoch_now>, Z_OPT = Z_OPT + 1
WHERE Z_PK = <note_pk>;Apple epoch = seconds since 2001-01-01T00:00:00Z.
The note's ZCLOUDSTATE column points to a ZICCLOUDSTATE row. Increment ZCURRENTLOCALVERSION so it exceeds ZLATESTVERSIONSYNCEDTOCLOUD:
UPDATE ZICCLOUDSTATE
SET ZCURRENTLOCALVERSION = ZCURRENTLOCALVERSION + 1,
ZLOCALVERSIONDATE = <apple_epoch_now>,
Z_OPT = Z_OPT + 1
WHERE Z_PK = <cloudstate_pk>;This is what tells the sync daemon "this record is dirty."
Create a transaction:
INSERT INTO ATRANSACTION (Z_PK, Z_ENT, Z_OPT, ZBUNDLEIDTS, ZPROCESSIDTS, ZTIMESTAMP)
VALUES (<next_pk>, 16002, NULL, 6, 7, <apple_epoch_now>);
-- ZBUNDLEIDTS=6 → "com.apple.Notes", ZPROCESSIDTS=7 → "Notes"Create change records (one per modified entity):
-- ICNoteData (entity 19): ZCOLUMNS = 0x10 (ZDATA column)
INSERT INTO ACHANGE (Z_PK, Z_ENT, Z_OPT, ZCHANGETYPE, ZENTITY, ZENTITYPK, ZTRANSACTIONID, ZCOLUMNS)
VALUES (<next_pk>, 16001, NULL, 1, 19, <notedata_pk>, <txn_pk>, X'10');
-- ICNote (entity 12): ZCOLUMNS = modification date bitmask
INSERT INTO ACHANGE (Z_PK, Z_ENT, Z_OPT, ZCHANGETYPE, ZENTITY, ZENTITYPK, ZTRANSACTIONID, ZCOLUMNS)
VALUES (<next_pk>, 16001, NULL, 1, 12, <note_pk>, <txn_pk>, X'00000000000000200000');
-- ICCloudState (entity 2): ZCOLUMNS = 0x24 (version + date columns)
INSERT INTO ACHANGE (Z_PK, Z_ENT, Z_OPT, ZCHANGETYPE, ZENTITY, ZENTITYPK, ZTRANSACTIONID, ZCOLUMNS)
VALUES (<next_pk>, 16001, NULL, 1, 2, <cloudstate_pk>, <txn_pk>, X'24');UPDATE Z_PRIMARYKEY SET Z_MAX = <new_max> WHERE Z_ENT = 16002; -- TRANSACTION
UPDATE Z_PRIMARYKEY SET Z_MAX = <new_max> WHERE Z_ENT = 16001; -- CHANGE- Notes.app has a file/database watcher and will pick up changes live (no restart needed).
- The sync daemon pushes the change to iCloud automatically once the cloud state version is bumped.
- ZBUNDLEIDTS and ZPROCESSIDTS reference rows in ATRANSACTIONSTRING (6 = "com.apple.Notes", 7 = "Notes").
- ZCHANGETYPE 1 = update, 0 = insert, 2 = delete.
- The ZCOLUMNS blob is a bitmask of which Core Data model properties changed.
- All of this should be done inside a single SQLite transaction for atomicity.
- Editing text without updating CRDT entries: The change displays locally but gets reverted on the next real edit through Notes.app.
- Writing to SQLite without Core Data tracking: The change displays locally (after app restart) but never syncs to iCloud. The sync daemon doesn't know the record is dirty.
- Direct SQLite writes while Notes.app is running: Notes.app may overwrite your changes on its next save. Close it first, or accept that it might detect the change via its database watcher.