Skip to content

Personal Diary Application

A personal diary is one of those projects where everything you build is genuinely useful. In this tutorial you will build a command-line journal that lets you write timestamped entries, tag each one with a mood, search through your past, filter by mood, and see stats on your journaling habit. Entries persist between runs via a JSON file you control entirely.

You will learn:

  • How to model entries as a class (and how @dataclass cuts that boilerplate).
  • How to read and write JSON with json.dump / json.load.
  • How to work with timestamps via datetime.
  • How to build a clean, menu-driven CLI without spaghetti.
  • How to add real privacy with optional encryption.
  • Python 3.6 or above (3.7+ recommended for dataclasses).
  • A text editor or IDE.
  • Comfort with functions, lists, and dictionaries.
  • Familiarity with running a .py file from the terminal.
ConceptPurpose
ClassGroup related data (entry fields) and behavior.
@dataclassAuto-generate __init__, __repr__, comparison — less boilerplate.
json.dump / json.loadPersist Python objects to a text file as JSON.
datetimeTimestamp entries with date and time.
Path / os.path.existsDetect whether the diary file is new or existing.
List comprehensionsFilter and search entries cleanly.
diagram how the pieces call each other mermaid
Derived from projects/beginners/personaldiary.py by parsing it, not by hand. Arrows are calls between the file's own functions and methods; library calls are left out, and only calls the parser could resolve with certainty are shown.
  1. Create a folder named personal-diary.
  2. Inside it, create personaldiary.py.
  3. (Optional) create diary.json — the script will create it for you on first save.
Personal Diary pch.viewSource
Personal Diary
# Personal Diary Application

import datetime
import os
import json


def ask(prompt="", default=""):
    """Read a line, or fall back to `default` when nobody is there to type.

    Without this the script raises EOFError the moment it runs unattended — in
    a test, a scheduled job, or the build that captures this output for the
    docs. The fallback is printed rather than silent, so a reader can always
    tell which answers were typed and which were assumed.
    """
    try:
        return input(prompt).strip() or default
    except EOFError:
        print(f"{default}   (no input available, using the default)")
        return default

class DiaryEntry:
    def __init__(self, date, title, content, mood="neutral"):
        self.date = date
        self.title = title
        self.content = content
        self.mood = mood
    
    def to_dict(self):
        return {
            'date': self.date.isoformat(),
            'title': self.title,
            'content': self.content,
            'mood': self.mood
        }
    
    @classmethod
    def from_dict(cls, data):
        return cls(
            datetime.datetime.fromisoformat(data['date']),
            data['title'],
            data['content'],
            data.get('mood', 'neutral')
        )

class PersonalDiary:
    def __init__(self, filename="diary.json"):
        self.filename = filename
        self.entries = []
        self.load_entries()
    
    def load_entries(self):
        """Load entries from file"""
        if os.path.exists(self.filename):
            try:
                with open(self.filename, 'r') as f:
                    data = json.load(f)
                    self.entries = [DiaryEntry.from_dict(entry) for entry in data]
            except (json.JSONDecodeError, KeyError):
                print("Warning: Could not load diary entries. Starting fresh.")
                self.entries = []
    
    def save_entries(self):
        """Save entries to file"""
        with open(self.filename, 'w') as f:
            json.dump([entry.to_dict() for entry in self.entries], f, indent=2)
    
    def add_entry(self, title, content, mood="neutral"):
        """Add a new diary entry"""
        entry = DiaryEntry(datetime.datetime.now(), title, content, mood)
        self.entries.append(entry)
        self.save_entries()
        print("Entry added successfully!")
    
    def view_entries(self):
        """Display all diary entries"""
        if not self.entries:
            print("No diary entries found.")
            return
        
        print("\n" + "="*50)
        print("YOUR DIARY ENTRIES")
        print("="*50)
        
        for i, entry in enumerate(self.entries, 1):
            print(f"\nEntry #{i}")
            print(f"Date: {entry.date.strftime('%Y-%m-%d %H:%M')}")
            print(f"Title: {entry.title}")
            print(f"Mood: {entry.mood}")
            print(f"Content: {entry.content}")
            print("-" * 30)
    
    def search_entries(self, keyword):
        """Search entries by keyword"""
        found_entries = []
        keyword_lower = keyword.lower()
        
        for entry in self.entries:
            if (keyword_lower in entry.title.lower() or 
                keyword_lower in entry.content.lower()):
                found_entries.append(entry)
        
        if not found_entries:
            print(f"No entries found containing '{keyword}'")
            return
        
        print(f"\nFound {len(found_entries)} entries containing '{keyword}':")
        print("="*50)
        
        for i, entry in enumerate(found_entries, 1):
            print(f"\nResult #{i}")
            print(f"Date: {entry.date.strftime('%Y-%m-%d %H:%M')}")
            print(f"Title: {entry.title}")
            print(f"Mood: {entry.mood}")
            print(f"Content: {entry.content}")
            print("-" * 30)
    
    def filter_by_mood(self, mood):
        """Filter entries by mood"""
        mood_entries = [entry for entry in self.entries if entry.mood.lower() == mood.lower()]
        
        if not mood_entries:
            print(f"No entries found with mood '{mood}'")
            return
        
        print(f"\nEntries with mood '{mood}':")
        print("="*50)
        
        for i, entry in enumerate(mood_entries, 1):
            print(f"\nEntry #{i}")
            print(f"Date: {entry.date.strftime('%Y-%m-%d %H:%M')}")
            print(f"Title: {entry.title}")
            print(f"Content: {entry.content}")
            print("-" * 30)
    
    def get_statistics(self):
        """Display diary statistics"""
        if not self.entries:
            print("No entries to analyze.")
            return
        
        total_entries = len(self.entries)
        mood_counts = {}
        
        for entry in self.entries:
            mood = entry.mood
            mood_counts[mood] = mood_counts.get(mood, 0) + 1
        
        print(f"\nDiary Statistics:")
        print("="*30)
        print(f"Total entries: {total_entries}")
        print(f"First entry: {min(self.entries, key=lambda x: x.date).date.strftime('%Y-%m-%d')}")
        print(f"Latest entry: {max(self.entries, key=lambda x: x.date).date.strftime('%Y-%m-%d')}")
        print(f"\nMood distribution:")
        for mood, count in mood_counts.items():
            percentage = (count / total_entries) * 100
            print(f"  {mood}: {count} ({percentage:.1f}%)")

def main():
    diary = PersonalDiary()
    
    while True:
        print("\n" + "="*40)
        print("PERSONAL DIARY APPLICATION")
        print("="*40)
        print("1. Add new entry")
        print("2. View all entries")
        print("3. Search entries")
        print("4. Filter by mood")
        print("5. View statistics")
        print("6. Exit")
        
        choice = ask("\nSelect an option (1-6): ", '6').strip()
        
        if choice == '1':
            print("\nAdding new diary entry:")
            title = ask("Enter title: ", '6').strip()
            print("Enter content (press Enter twice to finish):")
            content_lines = []
            while True:
                line = ask("", '6')
                if line == "":
                    break
                content_lines.append(line)
            content = "\n".join(content_lines)
            
            print("Select mood:")
            print("1. Happy  2. Sad  3. Excited  4. Anxious  5. Peaceful  6. Other")
            mood_choice = ask("Enter choice (1-6): ", '6').strip()
            
            mood_map = {'1': 'happy', '2': 'sad', '3': 'excited', 
                       '4': 'anxious', '5': 'peaceful', '6': 'other'}
            mood = mood_map.get(mood_choice, 'neutral')
            
            if mood == 'other':
                mood = ask("Enter custom mood: ", '6').strip() or 'neutral'
            
            diary.add_entry(title, content, mood)
        
        elif choice == '2':
            diary.view_entries()
        
        elif choice == '3':
            keyword = ask("Enter search keyword: ", 'python').strip()
            if keyword:
                diary.search_entries(keyword)
        
        elif choice == '4':
            mood = ask("Enter mood to filter by: ", '6').strip()
            if mood:
                diary.filter_by_mood(mood)
        
        elif choice == '5':
            diary.get_statistics()
        
        elif choice == '6':
            print("Thank you for using Personal Diary. Goodbye!")
            break
        
        else:
            print("Invalid choice. Please try again.")

if __name__ == "__main__":
    main()
run
python personaldiary.py
text
1. Add new entry
2. View all entries
3. Search entries
4. Filter by mood
5. View statistics
6. Exit
Choice: 1
Title: First entry
Mood (happy/sad/neutral/excited/anxious): happy
Content (end with empty line):
> Started my Python diary today.
>
Saved.

Running the file exactly as it ships takes 0.1 s and prints:

python personaldiary.py
 
========================================
PERSONAL DIARY APPLICATION
========================================
1. Add new entry
2. View all entries
3. Search entries
4. Filter by mood
5. View statistics
6. Exit
 
Select an option (1-6): 6   (no input available, using the default)
Thank you for using Personal Diary. Goodbye!
personaldiary.py
from dataclasses import dataclass, field, asdict
from datetime import datetime
 
@dataclass
class DiaryEntry:
    title: str
    content: str
    mood: str = "neutral"
    date: str = field(default_factory=lambda: datetime.now().isoformat(timespec="seconds"))
 
    def to_dict(self):
        return asdict(self)
 
    @classmethod
    def from_dict(cls, d):
        return cls(**d)

Why @dataclass? In one decorator you get:

  • A real __init__ that accepts each field.
  • A __repr__ for clean printing.
  • Equality based on field values.
  • Optional default values.

field(default_factory=...) is the right way to default a mutable or computed value — it runs each time a new entry is created, so every entry gets its own current timestamp.

personaldiary.py
import json
from pathlib import Path
 
class Diary:
    def __init__(self, filename="diary.json"):
        self.filename = Path(filename)
        self.entries: list[DiaryEntry] = []
        self.load()
 
    def load(self):
        if self.filename.exists():
            data = json.loads(self.filename.read_text(encoding="utf-8"))
            self.entries = [DiaryEntry.from_dict(d) for d in data]
 
    def save(self):
        data = [e.to_dict() for e in self.entries]
        self.filename.write_text(json.dumps(data, indent=2), encoding="utf-8")
 
    def add(self, entry: DiaryEntry):
        self.entries.append(entry)
        self.save()

Reading and writing the whole file each time is fine for personal use — JSON is small and humans like being able to open it in a text editor. If you ever had millions of entries you would swap to SQLite.

personaldiary.py
def search(self, keyword: str) -> list[DiaryEntry]:
    k = keyword.lower()
    return [e for e in self.entries
            if k in e.title.lower() or k in e.content.lower()]
 
def by_mood(self, mood: str) -> list[DiaryEntry]:
    return [e for e in self.entries if e.mood == mood]

List comprehensions read like English. The lower() calls make search case-insensitive.

personaldiary.py
from collections import Counter
 
def stats(self):
    if not self.entries:
        return {"total": 0, "moods": {}}
    moods = Counter(e.mood for e in self.entries)
    return {
        "total": len(self.entries),
        "moods": {m: f"{c} ({c/len(self.entries):.1%})"
                  for m, c in moods.most_common()},
        "first": self.entries[0].date,
        "latest": self.entries[-1].date,
    }

Counter is the right tool any time you find yourself reaching for dict plus +=.

personaldiary.py
def main():
    diary = Diary()
    while True:
        print("\n1. Add  2. View  3. Search  4. By mood  5. Stats  6. Exit")
        choice = input("Choice: ").strip()
        if choice == "1":
            add_entry(diary)
        elif choice == "2":
            view_all(diary)
        elif choice == "3":
            search_entries(diary)
        elif choice == "4":
            filter_by_mood(diary)
        elif choice == "5":
            show_stats(diary)
        elif choice == "6":
            break
        else:
            print("Invalid choice.")

Each menu option calls a separate helper function. Each helper does one thing. The main loop only routes.

personaldiary.py
def read_multiline(prompt: str) -> str:
    print(prompt + " (end with an empty line)")
    lines = []
    while True:
        line = input("> ")
        if line == "":
            break
        lines.append(line)
    return "\n".join(lines)

This pattern lets users write paragraphs, not just one line. Press Enter on an empty line to finish.

ProblemCauseFix
json.decoder.JSONDecodeError on first runFile exists but is emptyCheck if self.filename.exists() and self.filename.stat().st_size > 0
Entries lost after restartForgot self.save() after addingSave on every mutating operation, not just exit
Unicode errorsDefault encoding mismatchAlways pass encoding="utf-8" to read/write
KeyError after upgrading the schemaOld file lacks a new fieldUse .get() in from_dict or run a one-shot migration
Search returns nothingCase mismatch.lower() both sides

Number entries when displaying them; let the user type the number to edit or delete.

A diary should be private. Use cryptography.fernet:

encrypt.py
from cryptography.fernet import Fernet
key = Fernet(...).generate_key()           # store separately!
cipher = Fernet(key)
encrypted = cipher.encrypt(content.encode())

Save encrypted instead of the raw content. Decrypt on load. Better: derive the key from a user-provided password using PBKDF2HMAC.

“Show entries from last week” or “from 2025-01-01 to 2025-01-31”.

date_range.py
from datetime import datetime
start = datetime.fromisoformat("2025-01-01")
end   = datetime.fromisoformat("2025-01-31")
return [e for e in self.entries
        if start <= datetime.fromisoformat(e.date) <= end]

Add a tags: list[str] field; let the user filter by tag the same way as by mood.

Use TextBlob or VADER to compute a sentiment score per entry; correlate it with your self-reported mood.

Save entries as Markdown. Export the diary as a single Markdown or HTML file you can print.

Build a Tkinter UI with a list of dates on the left and the content on the right.

Sync the diary.json file to S3 / Google Drive on save. Encrypt before upload.

Use matplotlib to plot moods over time:

chart.py
import matplotlib.pyplot as plt
dates = [e.date for e in diary.entries]
moods = [e.mood for e in diary.entries]
plt.plot(dates, moods, marker="o")
plt.xticks(rotation=45)
plt.tight_layout(); plt.show()

Schedule a daily prompt at 9 PM to write today’s entry. See Basic Alarm Clock for the scheduling idea.

A diary on disk is plaintext by default. If you keep anything personal:

  • Move the diary file out of any auto-synced folder (Dropbox, OneDrive, iCloud) unless those services are encrypted at rest with your key.
  • Encrypt at rest with cryptography.fernet and a password-derived key.
  • Never commit your diary file to Git. Add diary.json to .gitignore.
  • Back up the file somewhere safe — losing it is permanent.
  • Personal journaling — what this is.
  • Mood / habit tracking — bullet-journal-style logs.
  • Reading log or workout log — same data structure, different fields.
  • Customer-service ticketing prototypes — entries are tickets, moods are priorities.
  • Bug-tracking for solo projects.
  • Data modeling with @dataclass.
  • Persistence with JSON, including schema evolution.
  • Searching and filtering with list comprehensions.
  • Counting and aggregation with Counter.
  • CLI design — clear menus, helper functions, no global state.
  • Build the encryption layer (cryptography.fernet).
  • Add a edit/delete flow.
  • Plot mood over time with matplotlib.
  • Wrap with a Tkinter GUI or a Flask web app.
  • Migrate storage from JSON to SQLite when you cross a few thousand entries.

You built a real productivity tool: timestamped, searchable, statistically aware. The same architecture (data class + persistence + menu loop) underlies thousands of small CLI tools used in the wild. The full source is on GitHub. Explore more on Python Central Hub.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading