Project: Personal Journal (with Search)
1 · The lesson
readYou'll build a journaling tool: write an entry, list past entries, search by keyword, get stats. The entries persist as a JSON file, so it's a real tool you could actually use day-to-day.
This is the most "useful in real life" project so far. Once it's working, you can run it from a terminal every morning and have a private journal nobody else can read.
What you'll practice: datetime, JSON persistence, dict-of-records pattern, command dispatch, list filtering, regex (lightly).
Step 1 — The Data Shape
Decide upfront what an entry looks like. We'll go with:
{
"id": "2026-05-14T09-30", # ISO-like timestamp, file-safe
"date": "2026-05-14",
"time": "09:30",
"title": "First entry", # optional, derived if blank
"body": "Today I started a journaling app...",
"tags": ["coding", "habits"],
}Simple, future-proof. Adding a field later (mood? location?) doesn't break old entries.
Step 2 — Adding an Entry
from datetime import datetime def create_entry(body, *, title="", tags=None): """Return a new journal entry dict.""" now = datetime.now() return { "id": now.strftime("%Y-%m-%dT%H-%M"), "date": now.strftime("%Y-%m-%d"), "time": now.strftime("%H:%M"), "title": title or body[:40].strip(), # first 40 chars if no title "body": body.strip(), "tags": tags or [], } # Test e = create_entry( "Started learning Python file I/O today. Built a tip calculator.", tags=["coding", "python"], ) import json print(json.dumps(e, indent=2))
A few choices worth calling out:
title or body[:40]— if no title, auto-derive from the start of the body. Real journals often don't have titles.tags or []— don't usetags=[]as a default argument! The Mutable Default Argument trap — defaults are shared between calls.- ISO-like IDs (
2026-05-14T09-30) — filename-safe AND sortable. Sort the entries by ID and they're chronological. No extra sort key needed.
Step 3 — Persistence
A journal that doesn't survive a restart isn't a journal.
import json import io # Real version: file path # JOURNAL_FILE = "journal.json" # Browser-sandbox stand-in storage = io.StringIO("[]") def load_journal(): try: storage.seek(0) return json.loads(storage.read()) except (FileNotFoundError, json.JSONDecodeError): return [] def save_journal(entries): storage.seek(0) storage.truncate() storage.write(json.dumps(entries, indent=2, ensure_ascii=False)) # Test the round-trip save_journal([{"id": "x", "body": "hello"}]) loaded = load_journal() print(loaded) # Real-file versions: # # def load_journal(): # try: # with open(JOURNAL_FILE, "r", encoding="utf-8") as f: # return json.load(f) # except (FileNotFoundError, json.JSONDecodeError): # return [] # # def save_journal(entries): # with open(JOURNAL_FILE, "w", encoding="utf-8") as f: # json.dump(entries, f, indent=2, ensure_ascii=False)
ensure_ascii=False lets us write actual Unicode characters (café, naïve, 🌱) instead of \u-escaped versions. Friendlier when you read the JSON file by eye.
Step 4 — Listing Entries
from datetime import datetime def format_entry(entry, *, body_length=80): body = entry["body"].replace("\n", " ") if len(body) > body_length: body = body[: body_length - 1] + "…" tags = " ".join(f"#{t}" for t in entry.get("tags", [])) return f"[{entry['date']} {entry['time']}] {entry['title']}\n {body}\n {tags}" # Demo data entries = [ {"id": "2026-05-14T09-30", "date": "2026-05-14", "time": "09:30", "title": "Started journal app", "body": "Built the data shape and persistence today.", "tags": ["coding"]}, {"id": "2026-05-14T18-00", "date": "2026-05-14", "time": "18:00", "title": "Finished work early", "body": "Time to read.", "tags": ["life"]}, {"id": "2026-05-15T08-00", "date": "2026-05-15", "time": "08:00", "title": "Morning thoughts", "body": "What if Python had Rust's borrow checker?", "tags": ["coding", "musing"]}, ] # Most recent first for e in sorted(entries, key=lambda e: e["id"], reverse=True): print(format_entry(e)) print()
sorted(..., key=lambda e: e["id"], reverse=True) — most recent first. The lambda is a tiny anonymous function. (There's a whole lambdas lesson — for now, just know key=lambda e: e["id"] means "compare entries by their id".)
Step 5 — Search
By keyword, by tag, by date range.
from datetime import datetime, date def search_by_keyword(entries, query): """Return entries containing the query (case-insensitive) in title or body.""" q = query.lower() return [ e for e in entries if q in e["title"].lower() or q in e["body"].lower() ] def search_by_tag(entries, tag): return [e for e in entries if tag in e.get("tags", [])] def search_by_date_range(entries, start_iso, end_iso): """`start_iso` and `end_iso` are 'YYYY-MM-DD' strings (inclusive).""" return [e for e in entries if start_iso <= e["date"] <= end_iso] # Demo print("Containing 'Python':") for e in search_by_keyword(entries, "python"): print(f" → {e['title']}") print("\nTagged 'coding':") for e in search_by_tag(entries, "coding"): print(f" → {e['title']}") print("\nFrom 2026-05-14 only:") for e in search_by_date_range(entries, "2026-05-14", "2026-05-14"): print(f" → {e['title']}")
Notice that ISO date strings compare correctly with < and <= — that's why we chose YYYY-MM-DD format. Lexicographic order matches chronological order. A small upfront decision that pays off forever.
Step 6 — Stats
from collections import Counter def journal_stats(entries): if not entries: return {"total": 0} word_count = sum(len(e["body"].split()) for e in entries) all_tags = [tag for e in entries for tag in e.get("tags", [])] tag_counter = Counter(all_tags) first = min(entries, key=lambda e: e["id"])["date"] last = max(entries, key=lambda e: e["id"])["date"] return { "total": len(entries), "total_words": word_count, "average_length": word_count // len(entries), "first_entry": first, "last_entry": last, "top_tags": tag_counter.most_common(5), } stats = journal_stats(entries) for k, v in stats.items(): print(f" {k}: {v}")
from x for e in entries for x in e["tags"] is a nested list comprehension — same as a flat for e in entries: for x in e["tags"]: ... loop, but as one expression. Common in idiomatic Python.
Step 7 — Putting It Together
import json import io from collections import Counter from datetime import datetime storage = io.StringIO("[]") def load_journal(): try: storage.seek(0) return json.loads(storage.read()) except (FileNotFoundError, json.JSONDecodeError): return [] def save_journal(entries): storage.seek(0); storage.truncate() storage.write(json.dumps(entries, indent=2, ensure_ascii=False)) def create_entry(body, *, title="", tags=None): now = datetime.now() return { "id": now.strftime("%Y-%m-%dT%H-%M"), "date": now.strftime("%Y-%m-%d"), "time": now.strftime("%H:%M"), "title": title or body[:40].strip(), "body": body.strip(), "tags": tags or [], } def search_keyword(entries, q): q = q.lower() return [e for e in entries if q in e["title"].lower() or q in e["body"].lower()] def stats(entries): if not entries: return {"total": 0} words = sum(len(e["body"].split()) for e in entries) return { "total": len(entries), "words": words, "avg_words": words // len(entries), "top_tags": Counter(t for e in entries for t in e["tags"]).most_common(3), } # Demo session journal = load_journal() journal.append(create_entry("First entry — building this journal app", tags=["coding", "habits"])) journal.append(create_entry("Took a walk. The trees were starting to change colour.", tags=["life"])) journal.append(create_entry("Discovered Counter. Will use this in EVERY project.", tags=["coding"])) save_journal(journal) print("Entries:") for e in journal: print(f" [{e['date']}] {e['title']}") print("\nSearch 'colour':") for e in search_keyword(journal, "colour"): print(f" → {e['title']}") print("\nStats:", stats(journal))
That's a real, working journal. Five tiny functions, persistence, search, stats.
Stretch Goals
1. Edit existing entries: lookup by ID, replace, save.
2. Markdown rendering: entries support Markdown; render to HTML for a "view" command.
3. Encrypt the file: use the cryptography library — your journal is private.
4. CLI with argparse: journal add "text", journal list, journal search keyword.
5. Web UI: serve via Flask (after the web lessons).
6. Mood tracking: add "mood": 1-10 field, plot a sparkline over time using statistics.
7. Smart prompts: at midnight, the day's first entry asks "How was today?" and "What did you learn?"
🎯 Your Turn — Tag Analytics
Write a function tag_trend(entries, tag) that returns how often a given tag was used per week. Lets you spot patterns like "I journal about coding more on weekends" or "running tag appeared 3 weeks in a row, then stopped".
from datetime import datetime from collections import Counter def tag_trend(entries, tag): """Return a dict mapping ISO week-string (e.g. '2026-W19') → count. Use datetime.strptime to parse each entry's date, then strftime('%G-W%V') for the ISO year-week format. """ # TODO 1: filter entries that contain the tag # TODO 2: parse each entry's date string into datetime # TODO 3: produce ISO year-week strings # TODO 4: Counter those, return as dict (or Counter — same thing) pass # Test entries = [ {"date": "2026-05-04", "tags": ["coding", "habits"]}, {"date": "2026-05-05", "tags": ["coding"]}, {"date": "2026-05-06", "tags": ["life"]}, {"date": "2026-05-11", "tags": ["coding"]}, {"date": "2026-05-12", "tags": ["coding", "ml"]}, {"date": "2026-05-19", "tags": ["coding"]}, ] print(tag_trend(entries, "coding")) # Expected ~ {'2026-W19': 2, '2026-W20': 2, '2026-W21': 1}
Hint 1 — Filtering
[e for e in entries if tag in e.get("tags", [])]
Hint 2 — ISO weeks
datetime.strptime(e["date"], "%Y-%m-%d").strftime("%G-W%V"). The capital %G and %V are the ISO-year and ISO-week, which align with how calendars handle year-boundaries cleanly.
Show full solution
from datetime import datetime from collections import Counter def tag_trend(entries, tag): matching = [e for e in entries if tag in e.get("tags", [])] weeks = [datetime.strptime(e["date"], "%Y-%m-%d").strftime("%G-W%V") for e in matching] return Counter(weeks) entries = [ {"date": "2026-05-04", "tags": ["coding", "habits"]}, {"date": "2026-05-05", "tags": ["coding"]}, {"date": "2026-05-06", "tags": ["life"]}, {"date": "2026-05-11", "tags": ["coding"]}, {"date": "2026-05-12", "tags": ["coding", "ml"]}, {"date": "2026-05-19", "tags": ["coding"]}, ] trend = tag_trend(entries, "coding") for week, count in sorted(trend.items()): bar = "█" * count print(f" {week} {bar} {count}")
What You Learned
datetime.strftimefor human-friendly timestamps- The dict-of-records pattern (each "row" is a dict, the list is a "table")
- JSON persistence with
ensure_ascii=Falsefor international text - Lambda + sorted/min/max for "by this field" sorting
- Nested list comprehensions for flattening nested data
- Why ISO date strings are worth the upfront choice
This is the project most likely to become a real tool you keep. Set up a real journal.json file on your machine, swap the StringIO stand-in for open(...), and write to it for a week. You'll find more features to add.
Next: the project track continues with Weather CLI and beyond — once we ship the API/networking lessons.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.