Spaced repetition is one of the few study methods with strong research behind it. In their 2013 review of ten common study techniques, Dunlosky and colleagues rated only two as “high utility”: practice testing and distributed practice. Flashcards in Anki give you both. Anki is free and open source, and it decides which card to show you on which day.
What stops most people is making the cards. Writing a good card takes about a minute. A 40-page chapter can produce 150 cards, so that’s two or three hours of typing before you’ve studied anything. Most students give up, or they download a shared deck that doesn’t match their course.
This guide builds a small two-script pipeline:
- pypdf pulls the text out of your PDF (lecture slides, handouts, your own notes, a textbook chapter).
- Gemini 3.5 Flash-Lite writes question/answer cards, a few pages at a time. For each card it also returns the exact sentence from the text that supports the answer.
- Python checks that sentence. If it doesn’t appear in the PDF text, the card is marked
REVIEWand isn’t trusted. - You go through a CSV in a spreadsheet, delete the weak cards, and genanki builds an
.apkgfile that you import into Anki.
AI cost is under $0.001 per page, so under $0.50 even for a 200-page book. Setup takes about 30 minutes. It’s mainly a quality-of-life tool for anyone studying for an exam, a certification or a language. Near the end there’s an honest section on how private tutors can use it in a paid service.
What you need
| Item | Cost | Notes |
|---|---|---|
| Python 3.10+ | $0 | Windows, macOS or Linux |
| Gemini API key | $0 to start | aistudio.google.com → “Get API key” |
google-genai, pydantic, pypdf, genanki | $0 | pip install |
| Anki desktop | $0 | apps.ankiweb.net. Free and open source |
| AnkiWeb sync + AnkiDroid (Android) | $0 | Both free |
| AnkiMobile (iPhone/iPad) | $24.99 one-off (US; varies by country) | Optional. The official iOS app is paid and helps fund development. Free alternative: review at ankiweb.net in Safari |
| A spreadsheet | $0 | Google Sheets or LibreOffice Calc for the review step |
| PDFs with real text in them | $0 | Scanned images need OCR first (see Reality check) |
Gemini pricing as of October 2026 (check ai.google.dev/pricing before relying on it, because Google changes models and prices often):
- Gemini 3.5 Flash-Lite (
gemini-3.5-flash-lite, paid tier): $0.30 per 1M input tokens, $2.50 per 1M output tokens. - Free tier: rate-limited, but fine for trying the pipeline on one chapter. Google’s terms allow free-tier content to be used to improve its products, so don’t put private material through it (a client’s documents, unpublished work, student data).
How the cost works out
The script sends 4 pages per request. A dense textbook page is about 500 words, which is roughly 700 tokens.
- Input per request: 4 pages × 700 tokens + ~300 tokens of instructions = about 3,100 tokens
- Output per request: 12 cards × ~70 tokens (question, answer, quote, JSON overhead) + some thinking = about 1,000 tokens
Per request: 3,100 × $0.30/1M + 1,000 × $2.50/1M = $0.0009 + $0.0025 = about $0.0034, or under $0.001 per page.
A 200-page book is 50 requests, so about $0.17. Allow two to three times that for dense pages, a few retries, and models that think more than expected, and you’re still under $0.50 per textbook. The script prints the real token counts and cost at the end of each run, so you can check this on your own files.
Output tokens cost about 8× more than input tokens, so the number of cards drives the cost more than the length of the PDF. That’s one more reason to keep the cards-per-page setting low.
Step 1: Set up the project
mkdir pdf2anki && cd pdf2anki
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install google-genai pydantic pypdf genanki
export GEMINI_API_KEY="your-key-here" # Windows PowerShell: $env:GEMINI_API_KEY="your-key-here"
If you’ll use paid-tier pricing, turn on billing for the Google Cloud project behind your key, and set a budget alert (for example $5) in Google Cloud Billing. A loop bug is cheaper to catch at $5 than at $500.
Check that your PDF actually contains text before you spend anything:
python -c "from pypdf import PdfReader; r=PdfReader('chapter3.pdf'); print(len(r.pages), 'pages'); print(r.pages[0].extract_text()[:500])"
If that prints an empty string or nonsense, the PDF is a scan. Fix that first (see Reality check).
Step 2: Generate cards into a CSV
Save this as make_cards.py:
import argparse, csv, re, sys, time
from pathlib import Path
from google import genai
from google.genai import types
from pydantic import BaseModel
from pypdf import PdfReader
MODEL = "gemini-3.5-flash-lite"
PAGES_PER_CHUNK = 4
PRICE_IN, PRICE_OUT = 0.30, 2.50 # USD per 1M tokens, check ai.google.dev/pricing
class Card(BaseModel):
front: str
back: str
quote: str # exact words from the text that support the answer
class Cards(BaseModel):
cards: list[Card]
PROMPT = """You write Anki flashcards from study material.
Rules:
- Use ONLY facts stated in the TEXT. Do not add outside knowledge.
- One fact per card. The question must make sense on its own (no "according to the text").
- Answers are short: at most 20 words.
- Skip examples, anecdotes, page headers, exercises, references and anything trivial.
- "quote" must be copied EXACTLY from the TEXT (max 30 words) and must support the answer.
- At most {max_cards} cards. Fewer is better if the text has little worth remembering.
- Write the cards in the same language as the TEXT.
TEXT (pages {first}-{last}):
{text}"""
def clean(text: str) -> str:
return re.sub(r"-\n(\w)", r"\1", text) # join words hyphenated across lines
def norm(s: str) -> str:
return re.sub(r"\W+", " ", s).lower().strip()
def ask(client, prompt: str):
for attempt in range(3):
try:
return client.models.generate_content(
model=MODEL, contents=prompt,
config=types.GenerateContentConfig(
response_mime_type="application/json", response_schema=Cards))
except Exception as e: # rate limits, timeouts
print(f" API error ({e}), retrying in {10 * (attempt + 1)}s")
time.sleep(10 * (attempt + 1))
sys.exit("Gave up after 3 attempts")
def main():
ap = argparse.ArgumentParser(description="PDF -> flashcards CSV")
ap.add_argument("pdf")
ap.add_argument("--pages", help="e.g. 12-40 (default: all)")
ap.add_argument("--per-page", type=int, default=3, help="max cards per page")
args = ap.parse_args()
reader = PdfReader(args.pdf)
start, end = map(int, args.pages.split("-")) if args.pages else (1, len(reader.pages))
end = min(end, len(reader.pages))
texts = [clean(reader.pages[n].extract_text() or "") for n in range(start - 1, end)]
client = genai.Client()
rows, seen, tok_in, tok_out, dupes = [], set(), 0, 0, 0
for i in range(0, len(texts), PAGES_PER_CHUNK):
group = texts[i:i + PAGES_PER_CHUNK]
first, last = start + i, start + i + len(group) - 1
text = "\n".join(group)
if len(text.strip()) < 200:
print(f"pages {first}-{last}: almost no text (scan or images?), skipped")
continue
resp = ask(client, PROMPT.format(max_cards=args.per_page * len(group),
first=first, last=last, text=text))
u = resp.usage_metadata
tok_in += u.prompt_token_count or 0
tok_out += (u.candidates_token_count or 0) + (u.thoughts_token_count or 0)
if resp.parsed is None:
print(f"pages {first}-{last}: no valid JSON returned, skipped")
continue
source = norm(text)
for c in resp.parsed.cards:
key = norm(c.front)
if not key or key in seen:
dupes += 1
continue
seen.add(key)
problems = []
if not norm(c.quote) or norm(c.quote) not in source:
problems.append("quote not found in PDF")
if len(c.back.split()) > 25:
problems.append("long answer")
rows.append({"status": "REVIEW" if problems else "ok",
"front": c.front.strip(), "back": c.back.strip(),
"quote": c.quote.strip(), "pages": f"{first}-{last}",
"problem": "; ".join(problems)})
print(f"pages {first}-{last}: {len(resp.parsed.cards)} cards")
out = Path(args.pdf).with_suffix(".cards.csv")
with open(out, "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=["status", "front", "back", "quote", "pages", "problem"])
w.writeheader()
w.writerows(rows)
flagged = sum(r["status"] == "REVIEW" for r in rows)
cost = tok_in * PRICE_IN / 1e6 + tok_out * PRICE_OUT / 1e6
print(f"\n{len(rows)} cards ({flagged} flagged REVIEW, {dupes} duplicates dropped) -> {out}")
print(f"tokens: {tok_in:,} in / {tok_out:,} out, about ${cost:.4f}")
if __name__ == "__main__":
main()
The design choices that matter:
- Small chunks, not the whole book. Gemini can read a whole textbook in one request, but if you ask for cards from 200 pages at once, the cards tend to bunch up in some chapters and skip others. Four pages per request covers the material evenly and gives every card a page range.
- The quote check is done by Python, not the model. The model has to show its evidence, and a plain string comparison decides whether that evidence exists. Punctuation and capitals are ignored so small formatting differences don’t trigger false alarms. A card based on something the model “knew” rather than something in your PDF will usually fail this check.
- Hyphenated line breaks are joined (
infor-+ new line +mationbecomesinformation) before anything else happens. Otherwise good quotes would fail the check just because of how the PDF was laid out. - Duplicates are dropped by question text. Textbooks repeat themselves in summaries and chapter reviews.
- Nothing goes into Anki automatically. The CSV is the checkpoint.
Run it on one chapter first:
python make_cards.py biology_ch3.pdf --pages 45-72 --per-page 3
Output looks like this (your numbers will differ):
pages 45-48: 11 cards
pages 49-52: 12 cards
pages 53-56: 9 cards
...
pages 69-72: 8 cards
71 cards (6 flagged REVIEW, 4 duplicates dropped) -> biology_ch3.cards.csv
tokens: 24,180 in / 6,950 out, about $0.0246
Step 3: Review the cards (don’t skip this)
Open biology_ch3.cards.csv in Google Sheets or LibreOffice Calc. If you use Excel, open it through Data → From Text/CSV with UTF-8 encoding, and save it back as CSV UTF-8 so accents and non-English text survive.
For each row:
REVIEWrows: compare the quote and the answer with the pages listed in thepagescolumn. Fix the card and set the status took, or delete the row.okrows: skim them anyway. Delete any card you’d skip in a real study session: trivia, things you already know cold, and questions with more than one correct answer (“What is one function of the liver?”).- Rewrite vague questions. “What is important about mitochondria?” becomes “Which organelle produces most of a cell’s ATP?”
Expect to delete 10–30% of the cards. Checking takes about 10–15 seconds per card, so a 70-card chapter is 15–20 minutes. Writing the same 70 cards by hand at a minute each would take more than an hour. Those times are rough estimates, so measure your own.
Step 4: Build the Anki deck
Save this as build_deck.py:
import csv, hashlib, html, sys
from pathlib import Path
import genanki
# Fixed model ID: keep it the same forever, or Anki creates duplicate note types.
MODEL = genanki.Model(
1607392319, "PDF Card (Front/Back/Source)",
fields=[{"name": "Front"}, {"name": "Back"}, {"name": "Source"}],
templates=[{
"name": "Card 1",
"qfmt": "{{Front}}",
"afmt": '{{FrontSide}}<hr id="answer">{{Back}}'
'<br><br><small style="color:#888">{{Source}}</small>',
}])
csv_path, deck_name = Path(sys.argv[1]), sys.argv[2]
deck_id = int(hashlib.sha1(deck_name.encode()).hexdigest()[:8], 16) # same name -> same deck
deck = genanki.Deck(deck_id, deck_name)
book = csv_path.name.removesuffix(".cards.csv")
added = 0
with open(csv_path, newline="", encoding="utf-8-sig") as f:
for row in csv.DictReader(f):
if row["status"].strip().lower() != "ok":
continue
source = f'{book}, p. {row["pages"]}: "{row["quote"]}"'
deck.add_note(genanki.Note(
model=MODEL,
fields=[html.escape(row["front"]), html.escape(row["back"]), html.escape(source)],
tags=[f'p{row["pages"]}'],
guid=genanki.guid_for(deck_name, row["front"])))
added += 1
out = csv_path.with_suffix("").with_suffix(".apkg")
genanki.Package(deck).write_to_file(out)
print(f"{added} cards -> {out}")
Run it:
python build_deck.py biology_ch3.cards.csv "Biology::Chapter 3"
Then in Anki desktop: File → Import, select biology_ch3.apkg, and sync. The deck appears on your phone after the next sync.
Three details in this script:
::in the deck name creates a subdeck, soBiology::Chapter 3sits underBiology. One subdeck per chapter makes it easy to pause a chapter.- The note ID (GUID) is built from the deck name and the question. If you fix a typo in an answer and re-import, Anki can update the existing note and keep its review history instead of adding a duplicate. If you change the question text, Anki treats it as a new card.
- Every answer shows its source (file, pages, quote) in small grey text. When a card looks wrong three weeks later, you can check it in ten seconds instead of losing trust in the whole deck.
One Anki setting worth changing: in the deck options, turn on FSRS (built in since Anki 23.10). It schedules reviews from your own recall history, and for most people it means fewer reviews for the same retention than the older default algorithm.
How to sell this as a service (for tutors)
Be realistic first: nobody pays for flashcards on their own. AnkiWeb has thousands of free shared decks, and anyone can run this script. What people pay for is a tutor whose material fits their exact syllabus, and that’s where this pipeline helps.
1. Cut your unpaid prep time. Private tutors spend hours each week making practice material that nobody pays for directly. Turning a student’s school handouts into a reviewed deck should take roughly 20–30 minutes per chapter with this pipeline (an estimate; time your own first few). The AI doesn’t raise your hourly rate. It gives you back prep hours, which you can use to take on another student.
2. Add a “personal deck” to a package. For example, a monthly exam-prep package that includes a deck built from that student’s teacher handouts and past quizzes, updated every week. Parents can see what you prepared, and Anki’s statistics screen shows whether the student is actually doing their reviews. Price it into the package, not per card.
3. Set it up for a small tutoring centre (bimbel). Install the scripts on one office computer, train one staff member on the review step, and agree on one rule: no deck goes to students without a person checking it. Charge a one-off setup fee, plus optional monthly support.
Honest numbers for planning:
| Item | Cost / time |
|---|---|
| Gemini API per chapter (~30 pages) | About $0.03 (estimate above; your run prints the real figure) |
| Software (Anki, Python, libraries) | $0 |
| Your review time per chapter | 15–30 minutes |
| AnkiMobile, if the student uses an iPhone | $24.99 one-off in the US (varies by country), paid by the student; ankiweb.net in a browser is free |
Tutoring rates vary a lot by city, level and subject, so check what tutors near you charge before you price the add-on. Sell the time it saves and the fit with the student’s syllabus, not “AI-powered”.
Copyright: decks made from a student’s own notes, the handouts their teacher uses in class, or your own material are fine for private study. Decks copied from a commercial textbook are not something you can sell or share publicly. If you want to sell decks, build them from material you wrote yourself.
Reality check: what does NOT work
Scanned PDFs. pypdf only reads text that’s already in the file. Photocopied handouts and phone scans come back empty, and the script skips those pages. Run them through OCR first. OCRmyPDF is free and adds a text layer while keeping the PDF looking the same.
Maths, chemistry and anything visual. PDF text extraction turns equations into scrambled characters and drops diagrams entirely. Cards from those pages are unreliable or miss the point. For STEM subjects, use this pipeline for definitions and concepts, and make formula and diagram cards by hand (Anki’s built-in Image Occlusion, added in version 23.10, handles diagrams).
Tables and slides with very little text. A table often comes out as one long jumbled line. Slide decks with three words per slide produce vague cards. Use your own lecture notes instead.
Making too many cards. This is the most common mistake, and it’s why the script defaults to 3 cards per page. Anki shows 20 new cards a day by default. A 1,500-card textbook deck is 75 days of new cards, plus all the reviews they create. People build a huge deck in an afternoon, face hundreds of reviews a day within a few weeks, and quit. Make cards one chapter at a time, as you study it.
Treating “ok” as “correct”. The quote check proves the quote exists in your PDF. It doesn’t prove the answer matches the quote, or that the question is fair. That’s why the review step exists. And if your source material is wrong, the cards will faithfully copy the mistake.
Using flashcards to learn something for the first time. Cards are for remembering what you already understand. Read the chapter or attend the lesson first, then use the deck. A student who memorises 200 definitions without understanding the chapter will still struggle with application questions.
Putting private material through the free tier. Use the paid tier, which costs a few cents per chapter, for anything that belongs to a client, a school or a student.
Assuming other languages work exactly like English. Gemini handles Indonesian and other major languages well, but check the first batch carefully. The prompt asks for cards in the same language as the source, and occasionally a card still comes back in English. Fix those in the CSV.
Final checklist
- Test extraction shows real text from your PDF (otherwise OCR it first)
- First run on one chapter, not the whole book
- Every
REVIEWcard fixed or deleted, and weakokcards deleted too - Deck name uses
Subject::Chapterso chapters can be paused - FSRS turned on in the deck options
- Budget alert set if you use the paid tier
- No decks shared or sold from someone else’s copyrighted textbook
Start with one chapter you’re studying this week. Run the script, spend 15 minutes on the review, and do your Anki reviews every day for a week. If your recall improves and the daily load feels manageable, add the next chapter.