For years, I used Google Keep. But since I'm otherwise invested in the Apple ecosystem, I decided to switch to Apple Notes.
One thing I didn't want to lose with the switch was access to all my Keep notes from over the years. Turns out there's a pretty straightforward way to do this, even though there isn't a built-in way to do this in Google Keep or Apple Notes.
You can even preserve your original creation/edit dates and include any attached images.
- Go to Google Takeout and export all of your Google Keep. Depending on how much you have, it might take a little bit of time. Eventually, Google will send you a zip or a tgz. Download it and expand it to a folder.
- Download this Python script (also included at the bottom of this page) and save it in the same place as your Google Takeout export. (You will need Python 3 installed on your computer, but this works with the standard library. You won't need to do any pip installs.)
- Open your terminal and go to the folder where your Takeout export and Python script are located. Run this command:
python3 keep_to_enex.py TakeoutIf all is successful, you'll get an output in your terminal of the number of notes converted, number of image attachments included, and a new enex file in the same location as the Python script.
If you have labels on your notes in Google Keep, they will map to Tags in Apple Notes.
- Open Apple Notes. Click File → Import to Notes and select the newly created enex file.
I linked to my Python script above, but for the sake of completeness, here it is if you just wanted to copy/paste:
#!/usr/bin/env python3
"""
keep_to_enex.py — Convert a Google Keep Takeout export to an ENEX file
that Apple Notes can import (File > Import to Notes) with images embedded.
Requires only the Python 3 standard library. No pip installs.
Usage:
python3 keep_to_enex.py <Takeout folder | takeout.zip | takeout.tgz> [more inputs...] -o keep.enex
Examples:
python3 keep_to_enex.py ~/Downloads/Takeout -o keep.enex
python3 keep_to_enex.py ~/Downloads/takeout-20260816.tgz -o keep.enex
python3 keep_to_enex.py takeout-001.zip takeout-002.zip -o keep.enex
Options:
-o / --output Output .enex path (default: keep.enex)
--chunk N Split output into files of N notes each (default: all in one)
--include-trashed Include notes that were in Keep's trash (skipped by default)
--skip-archived Skip archived notes (included and tagged 'archived' by default)
What it handles:
* Text notes, checklists (become real checkboxes in Apple Notes)
* Images and drawings, embedded inline (base64 <resource> blocks)
* Keep's filename mismatch bug (JSON says .jpeg, file on disk is .jpg, etc.)
* Multi-part Takeout exports (images in a different zip than the JSON)
* Labels -> tags, archived -> 'archived' tag
* Created/edited timestamps
* Web link annotations appended at the end of the note
"""
import argparse
import base64
import hashlib
import json
import mimetypes
import sys
import tarfile
import tempfile
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from xml.sax.saxutils import escape
# Extension pairs Keep's Takeout is known to confuse between the JSON
# "filePath" reference and the actual file it ships.
EXT_SWAPS = {
".jpeg": [".jpg", ".png"],
".jpg": [".jpeg", ".png"],
".png": [".jpg", ".jpeg"],
".3gp": [".3gpp", ".m4a", ".aac"],
".3gpp": [".3gp", ".m4a", ".aac"],
".gif": [".png", ".jpg"],
}
MIME_FALLBACKS = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".gif": "image/gif", ".webp": "image/webp",
".3gp": "audio/3gpp", ".3gpp": "audio/3gpp",
".m4a": "audio/mp4", ".aac": "audio/aac", ".mp3": "audio/mpeg",
".pdf": "application/pdf",
}
def usec_to_enex(usec):
"""Keep timestamps are microseconds since epoch -> ENEX '20230101T120000Z'."""
try:
dt = datetime.fromtimestamp(int(usec) / 1_000_000, tz=timezone.utc)
return dt.strftime("%Y%m%dT%H%M%SZ")
except (TypeError, ValueError, OSError):
return None
def build_file_index(keep_dirs):
"""Map every non-JSON/HTML file across all Keep dirs.
Indexed by exact name (lowercased) and by stem (lowercased) so we can
recover from Takeout's extension mismatches.
"""
by_name, by_stem = {}, {}
for d in keep_dirs:
for p in d.iterdir():
if p.is_file() and p.suffix.lower() not in (".json", ".html", ".txt"):
by_name.setdefault(p.name.lower(), p)
by_stem.setdefault(p.stem.lower(), p)
return by_name, by_stem
def resolve_attachment(file_path, by_name, by_stem):
"""Find the actual file for a JSON attachment reference, tolerating
the .jpeg/.jpg (and similar) mismatches Takeout is known for."""
ref = Path(file_path)
name = ref.name.lower()
if name in by_name:
return by_name[name]
for alt_ext in EXT_SWAPS.get(ref.suffix.lower(), []):
alt = (ref.stem + alt_ext).lower()
if alt in by_name:
return by_name[alt]
return by_stem.get(ref.stem.lower())
def guess_mime(path, json_mime):
ext = path.suffix.lower()
if ext in MIME_FALLBACKS:
return MIME_FALLBACKS[ext]
guessed, _ = mimetypes.guess_type(path.name)
return guessed or json_mime or "application/octet-stream"
def enml_body(note, resources):
"""Build the ENML content document for one note."""
lines = []
if note.get("textContent"):
for line in note["textContent"].split("\n"):
lines.append(f"<div>{escape(line)}</div>" if line.strip()
else "<div><br/></div>")
for item in note.get("listContent", []):
checked = "true" if item.get("isChecked") else "false"
text = escape(item.get("text", ""))
lines.append(f'<div><en-todo checked="{checked}"/>{text}</div>')
for res in resources:
lines.append(f'<div><en-media type="{res["mime"]}" hash="{res["md5"]}"/></div>')
links = [a for a in note.get("annotations", [])
if a.get("url") and a.get("source") == "WEBLINK"]
if links:
lines.append("<div><br/></div>")
for a in links:
url = escape(a["url"], {'"': """})
label = escape(a.get("title") or a["url"])
lines.append(f'<div><a href="{url}">{label}</a></div>')
if not lines:
lines.append("<div><br/></div>")
body = "".join(lines)
return ('<?xml version="1.0" encoding="UTF-8"?>'
'<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">'
f"<en-note>{body}</en-note>")
def note_to_xml(note, by_name, by_stem, stats):
title = (note.get("title") or "").strip()
if not title:
text = (note.get("textContent") or "").strip()
title = text.split("\n")[0][:80] if text else "Untitled note"
resources = []
for att in note.get("attachments", []):
path = resolve_attachment(att.get("filePath", ""), by_name, by_stem)
if path is None:
stats["missing"].append((title, att.get("filePath", "?")))
continue
data = path.read_bytes()
resources.append({
"md5": hashlib.md5(data).hexdigest(),
"mime": guess_mime(path, att.get("mimetype")),
"b64": base64.encodebytes(data).decode("ascii"),
"filename": path.name,
})
stats["attachments"] += 1
parts = [f"<note><title>{escape(title)}</title>"]
parts.append(f"<content><![CDATA[{enml_body(note, resources)}]]></content>")
created = usec_to_enex(note.get("createdTimestampUsec"))
updated = usec_to_enex(note.get("userEditedTimestampUsec"))
if created:
parts.append(f"<created>{created}</created>")
if updated:
parts.append(f"<updated>{updated}</updated>")
tags = [l.get("name") for l in note.get("labels", []) if l.get("name")]
if note.get("isArchived"):
tags.append("archived")
for t in tags:
parts.append(f"<tag>{escape(t)}</tag>")
for res in resources:
parts.append(
"<resource>"
f'<data encoding="base64">{res["b64"]}</data>'
f"<mime>{res['mime']}</mime>"
"<resource-attributes>"
f"<file-name>{escape(res['filename'])}</file-name>"
"</resource-attributes>"
"</resource>"
)
parts.append("</note>")
return "".join(parts)
def find_keep_dirs(inputs, tmp_root):
"""Accept zips and/or folders; return every 'Keep' directory found."""
keep_dirs = []
for i, raw in enumerate(inputs):
p = Path(raw).expanduser()
if not p.exists():
sys.exit(f"error: input not found: {p}")
if p.is_file() and p.suffix.lower() == ".zip":
dest = Path(tmp_root) / f"zip{i}"
with zipfile.ZipFile(p) as z:
z.extractall(dest)
found = [d for d in dest.rglob("Keep") if d.is_dir()]
if not found:
print(f"warning: no 'Keep' folder inside {p.name}", file=sys.stderr)
keep_dirs.extend(found)
elif p.is_file() and (p.name.lower().endswith(".tgz")
or p.name.lower().endswith(".tar.gz")):
dest = Path(tmp_root) / f"tgz{i}"
with tarfile.open(p, "r:gz") as t:
try:
t.extractall(dest, filter="data")
except TypeError: # Python < 3.12 has no 'filter' argument
t.extractall(dest)
found = [d for d in dest.rglob("Keep") if d.is_dir()]
if not found:
print(f"warning: no 'Keep' folder inside {p.name}", file=sys.stderr)
keep_dirs.extend(found)
elif p.is_dir():
if p.name == "Keep" or any(p.glob("*.json")):
keep_dirs.append(p)
else:
keep_dirs.extend(d for d in p.rglob("Keep") if d.is_dir())
else:
sys.exit(f"error: expected a .zip, .tgz, or a folder, got: {p}")
if not keep_dirs:
sys.exit("error: no Keep data found in the given input(s)")
return keep_dirs
def write_enex(notes_xml, out_path):
export_date = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
header = ('<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE en-export SYSTEM '
'"http://xml.evernote.com/pub/evernote-export4.dtd">\n'
f'<en-export export-date="{export_date}" '
'application="keep_to_enex" version="1.0">\n')
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(header + "\n".join(notes_xml) + "\n</en-export>\n",
encoding="utf-8")
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("inputs", nargs="+",
help="Takeout .zip file(s) and/or extracted Keep folder(s)")
ap.add_argument("-o", "--output", default="keep.enex")
ap.add_argument("--chunk", type=int, default=0,
help="split into files of N notes each")
ap.add_argument("--include-trashed", action="store_true")
ap.add_argument("--skip-archived", action="store_true")
args = ap.parse_args()
stats = {"notes": 0, "attachments": 0, "missing": [],
"skipped_trashed": 0, "skipped_archived": 0}
with tempfile.TemporaryDirectory() as tmp:
keep_dirs = find_keep_dirs(args.inputs, tmp)
by_name, by_stem = build_file_index(keep_dirs)
json_files = sorted({f for d in keep_dirs for f in d.glob("*.json")},
key=lambda f: f.name)
notes_xml = []
for jf in json_files:
try:
note = json.loads(jf.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
print(f"warning: skipping unreadable {jf.name}: {e}", file=sys.stderr)
continue
if not isinstance(note, dict) or "textContent" not in note \
and "listContent" not in note and "attachments" not in note:
continue # not a note file (e.g., settings json)
if note.get("isTrashed") and not args.include_trashed:
stats["skipped_trashed"] += 1
continue
if note.get("isArchived") and args.skip_archived:
stats["skipped_archived"] += 1
continue
notes_xml.append(note_to_xml(note, by_name, by_stem, stats))
stats["notes"] += 1
if not notes_xml:
sys.exit("error: no notes converted — check the input path")
out = Path(args.output).expanduser()
if args.chunk and args.chunk > 0:
for i in range(0, len(notes_xml), args.chunk):
part = out.with_name(f"{out.stem}-{i // args.chunk + 1:03d}.enex")
write_enex(notes_xml[i:i + args.chunk], part)
print(f"wrote {part}")
else:
write_enex(notes_xml, out)
print(f"wrote {out}")
print(f"\nConverted {stats['notes']} notes, "
f"embedded {stats['attachments']} attachments.")
if stats["skipped_trashed"]:
print(f"Skipped {stats['skipped_trashed']} trashed notes "
"(use --include-trashed to keep them).")
if stats["skipped_archived"]:
print(f"Skipped {stats['skipped_archived']} archived notes.")
if stats["missing"]:
print(f"\nWARNING: {len(stats['missing'])} attachment(s) referenced "
"but not found in the export:")
for title, fp in stats["missing"][:20]:
print(f" - {title!r}: {fp}")
if len(stats["missing"]) > 20:
print(f" ... and {len(stats['missing']) - 20} more")
print("If your Takeout was split into multiple zips, pass them all "
"on the command line together.")
if __name__ == "__main__":
main()