Project: Personal Journal (with Search)
1 · The lesson
readYou'll build a journaling tool. You can write an entry, list past entries, search by keyword and see some statistics. The entries are saved in a JSON file, so you can use this tool every day.
This is the most useful project so far. Once it works, you can run it from a terminal every morning, and your journal stays in a file on your own computer.
What you'll practise: datetime, JSON persistence, dict-of-records pattern, command dispatch, list filtering, regex (lightly).
Step 1: The Data Shape
First, decide what an entry looks like. We'll use this:
{
"id": "2026-05-14T09-30-00", # like an ISO timestamp, but safe to use in a file name
"date": "2026-05-14",
"time": "09:30",
"title": "First entry", # optional, made from the body if blank
"body": "Today I started a journaling app...",
"tags": ["coding", "habits"],
}This shape is simple, and it is easy to extend. If you add a field later (mood, location), the old entries still work.
Step 2: Adding an Entry
from datetime import datetime def create_entry(body, *, title="", tags=None, journal=()): """Return a new journal entry dict. `journal` is the list of existing entries.""" now = datetime.now() # The ID is the time to the second. If an entry in `journal` already has that ID # (two entries in the same second), add -02, -03, ... so every ID is unique. used = [e["id"] for e in journal] entry_id = now.strftime("%Y-%m-%dT%H-%M-%S") n = 2 while entry_id in used: entry_id = now.strftime("%Y-%m-%dT%H-%M-%S") + f"-{n:02d}" n += 1 return { "id": entry_id, "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))
Some choices to notice:
title or body[:40]: if there is no title, the code uses the start of the body as the title. Many journal entries don't have titles.tags or []: don't usetags=[]as a default argument! The Mutable Default Argument trap: a default list is created once and shared between all calls.- ISO-like IDs (
2026-05-14T09-30-00): they are safe to use in filenames, and they sort correctly. If you sort the entries by ID, they are in date order. You don't need a separate sort key. - Unique IDs: a program can create several entries in the same second, so
create_entrychecks the IDs already injournaland adds-02,-03and so on when needed. The two digits keep the sort order correct (-10comes after-09).
Step 3: Persistence
A journal must keep its entries after the program ends, so we save them to a file.
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 writes Unicode characters (café, naïve, 🌱) as they are, instead of \u escape codes. This makes the JSON file easier to read when you open it.
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-00", "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-00", "date": "2026-05-14", "time": "18:00", "title": "Finished work early", "body": "Time to read.", "tags": ["life"]}, {"id": "2026-05-15T08-00-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) puts the most recent entry first. The lambda is a small function without a name. (There's a full lambdas lesson. For now, just know that key=lambda e: e["id"] means "compare entries by their id".)
Step 5: Search
We'll search in three ways: by keyword, by tag and 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 the YYYY-MM-DD format: for these strings, alphabetical order is the same as date order.
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}")
[tag for e in entries for tag in e.get("tags", [])] is a nested list comprehension. It does the same as the loop for e in entries: for tag in e.get("tags", []): ..., but in one expression. You will see this often in Python code.
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, journal=()): now = datetime.now() used = [e["id"] for e in journal] entry_id = now.strftime("%Y-%m-%dT%H-%M-%S") n = 2 while entry_id in used: # same second as an existing entry entry_id = now.strftime("%Y-%m-%dT%H-%M-%S") + f"-{n:02d}" n += 1 return { "id": entry_id, "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=journal)) journal.append(create_entry("Took a walk. The trees were starting to change colour.", tags=["life"], journal=journal)) journal.append(create_entry("Discovered Counter. Will use this in EVERY project.", tags=["coding"], journal=journal)) 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 small functions that give you saving, search and statistics.
Stretch Goals
1. Edit existing entries: lookup by ID, replace, save.
2. Markdown rendering: entries support Markdown. Render them to HTML for a "view" command.
3. Encrypt the file: use the cryptography library, so that nobody else can read your journal.
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 a "mood": 1-10 field, and use statistics to show your average mood for each week.
7. Smart prompts: when you write the first entry of a day, ask "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 in each week. With it you can see patterns like "the 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 (for example '2026-W19') to a 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: count them with Counter, return a dict (a Counter is a dict too) 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 roughly {'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 the ISO week. For a few days around 1 January, the ISO year can be different from the calendar year, so use %G together with %V.
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 a good choice: they sort in date order
You can turn this project into a tool you really use. Create a journal.json file on your computer, replace the StringIO stand-in with open(...), and write in it for a week. You'll think of more features to add.
Next: Weather CLI, a program that gets data from the internet.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code did, as well as whether a test passed.
Found this useful? Share it with a friend who’s learning Python.