Todo App with Persistence
Build a todo app with persistence in Python for mobile. Learn to save tasks locally, handle app restarts, and apply best practices.
Focus: create a todo app with persistence
You’ve just built a beautiful todo app, added tasks, checked them off—then you close the app and reopen it. Every single task is gone. That moment of frustration is the exact pain point this lesson solves. Without persistence, your app is a toy; with persistence, it becomes a tool users trust. Today you’ll learn how to create a todo app with persistence using Python and Kivy, storing tasks locally so they survive app restarts, and you’ll gain a mental model that applies to any mobile app dealing with user data.
The problem this lesson solves
Mobile apps live in a world of constant interruption. Users swipe away apps, their phones reboot, the OS kills background processes. Without persistence, every one of those events wipes out the user’s data. For a todo app, that’s not just inconvenient—it destroys the core value proposition. Users expect their task list to be there tomorrow, next week, after a flight, and after a phone update.
The specific problem we solve here is: how do we save todo items to a local storage layer and load them back when the app starts? We need a solution that is simple, fast, and works offline. No cloud account required, no network dependency, just reliable local persistence.
Pro tip: Persistence isn’t optional polish. It’s a core requirement for any app that manages user-generated data. If your data disappears, your users do too.
Core concept / mental model
Think of your app as a desk. The UI is the desktop where you put notes and reminders. Persistence is the drawer underneath. When you close the app, the OS “cleans the desk.” But if you quickly toss everything into the drawer (a file, a database), you can pull it out again when the app reopens.
In practical terms, this lesson introduces three key concepts:
- UI layer: Kivy widgets like
TextInput,Button, andListview(orRecycleView)—these are your visual desk. - Data model: A
TodoItemclass (or simple dict) representing each task with text, creation date, and done status. - Persistence layer: A storage mechanism (JSON file, SQLite database, or platform key-value store) that serializes your data model to disk.
We’ll use JSON for its simplicity and human readability, but you’ll also see how SQLite fits for more complex queries. The mental model to hold onto: the UI is volatile, the data layer is permanent.
How it works step by step
- Define your data model – Create a
TodoItemrepresentation that captures everything about a task. - Serialization – Convert that in-memory object into a text format (JSON) that can be written to a file.
- Write to storage – Save the serialized data to a file in the app’s local storage directory.
- Load on startup – When the app initializes, read the file, deserialize, and populate the UI.
- Handle updates – Every time the user adds, deletes, or toggles a task, write the entire list back to storage.
- Handle edge cases – What happens if the file is corrupt, missing, or the app crashes mid-write?
This flow is cyclical: load → display → user interaction → save → load again on next launch.
Hands-on walkthrough
Let’s build a minimal but complete todo app with persistence using Kivy and Python. We’ll keep the UI simple (a TextInput, an Add button, and a Listview), and focus on the persistence logic.
First, create a TodoItem class and a Storage helper:
# models.py
from datetime import datetime
import json
import os
class TodoItem:
def __init__(self, text, created_at=None, done=False):
self.text = text
self.done = done
self.created_at = created_at or datetime.utcnow().isoformat()
def to_dict(self):
return {"text": self.text, "done": self.done, "created_at": self.created_at}
@classmethod
def from_dict(cls, data):
return cls(data["text"], data["created_at"], data["done"])
class TodoStorage:
def __init__(self, filename="todos.json"):
# Kivy app data dir is platform-specific; we'll use a fixed path for simplicity
self.filepath = os.path.join(os.path.expanduser("~/.local/share"), filename)
def load(self):
if not os.path.exists(self.filepath):
return []
with open(self.filepath, "r") as f:
data = json.load(f)
return [TodoItem.from_dict(item) for item in data]
def save(self, items):
with open(self.filepath, "w") as f:
json.dump([item.to_dict() for item in items], f, indent=2)
Now the main app using Kivy:
# main.py
from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.textinput import TextInput
from kivy.uix.listview import ListView, ListItemLabel
from models import TodoItem, TodoStorage
class TodoApp(App):
def build(self):
self.storage = TodoStorage()
self.todos = self.storage.load()
root = BoxLayout(orientation="vertical")
self.input = TextInput(hint_text="Add a task...", size_hint_y=None, height=50)
root.add_widget(self.input)
add_btn = Button(text="Add", size_hint_y=None, height=50)
add_btn.bind(on_press=self.add_todo)
root.add_widget(add_btn)
self.list_view = ListView(item_strings=[t.text for t in self.todos])
root.add_widget(self.list_view)
return root
def add_todo(self, instance):
text = self.input.text.strip()
if text:
new_item = TodoItem(text)
self.todos.append(new_item)
self.storage.save(self.todos)
self.list_view.adapter.data = [t.text for t in self.todos]
self.list_view.adapter.notify_dataset_changed()
self.input.text = ""
if __name__ == "__main__":
TodoApp().run()
When you run this app, add a few tasks, close it, and reopen it. Your tasks should reappear from the JSON file.
Note: For production Kivy apps, use
App.get_running_app().user_data_dirfor a platform-appropriate storage path. In this example, we used a fixed path for clarity.
Let’s test with a quick command-line simulation to see the persistence logic in isolation:
# test_persistence.py
from models import TodoItem, TodoStorage
storage = TodoStorage()
items = [TodoItem("Buy milk"), TodoItem("Walk dog", done=True)]
storage.save(items)
loaded = storage.load()
print(f"Loaded {len(loaded)} todos:")
for todo in loaded:
print(f"- {todo.text} (done: {todo.done})")
Expected output:
Loaded 2 todos:
- Buy milk (done: False)
- Walk dog (done: True)
Now you’ve proven that persistence works independent of the UI.
Compare options / when to choose what
Persistence in mobile apps isn’t one-size-fits-all. Here’s a comparison of common options:
| Storage method | Pros | Cons | Best for |
|---|---|---|---|
| JSON file | Simple, human-readable, easy to debug | Loads entire file into memory; not great for huge datasets or complex queries | Todo apps, settings, small data |
| SQLite database | Fast queries, relational, supports indexes | More setup, SQL knowledge, slightly heavier | Larger datasets, search, relations |
| Platform key-value (e.g., SharedPreferences on Android) | Extremely fast, native, simple API | Only strings/numbers, no structures | Simple flags, last session state |
For this lesson’s todo app, JSON is the right call. It maps directly to Python objects, keeps the code readable, and performs fine for hundreds of tasks.
If your app grows to thousands of tasks with due dates, categories, and reminders, you’ll want to migrate to SQLite—that’s a natural progression we’ll mention in the next lesson.
Pro tip: Don’t over-engineer persistence. If a JSON file handles your data gracefully, you don’t need a database yet. Add complexity only when the problem demands it.
Troubleshooting & edge cases
You’ll run into real issues when building persistence. Here’s what to watch for:
- File not found on first launch – Your
load()should handle a missing file gracefully, returning an empty list. Our code does this withif not os.path.exists(...). If you skip this, you’ll get aFileNotFoundErrorand the app will crash. - Corrupt JSON – If the file gets truncated or corrupted (e.g., power loss mid-save),
json.load()will throwJSONDecodeError. In production, you might wrapload()in a try/except and fall back to an empty list or a backup file. - Accumulating duplicates – If you save the full list every time, and you accidentally append to the old list before loading, you’ll get duplicates. Always load fresh at startup and then mutate.
- KeyErrors when loading – If you change your
TodoItemschema (e.g., add a field), old JSON files won’t have it. Usefrom_dictwithdata.get("text", "")and.get("created_at", datetime.now().isoformat())for graceful defaults. - Permission errors on Android/iOS – Always use the app’s data directory, not a path in the root, to avoid
PermissionError. Kivy’suser_data_dirsolves this.
Example of a robust from_dict:
@classmethod
def from_dict(cls, data):
return cls(
text=data.get("text", ""),
created_at=data.get("created_at", datetime.utcnow().isoformat()),
done=data.get("done", False)
)
Now the app will survive schema changes with minimal breakage.
What you learned & what's next
You’ve just moved your todo app from volatile to permanent. You now understand that persistence bridges the gap between the user’s interaction and the device’s memory, using a simple file to store the entire data model. You learned how to serialize Python objects to JSON, write them to disk, load them on startup, and handle edge cases like missing files and schema evolution.
You also saw how to compare persistence options—JSON vs. SQLite vs. platform key-value stores—and why JSON is the right starting point for a todo app.
You’ve achieved the core learning objectives: you can explain why persistence matters, and you’ve completed a practical exercise that proves it works.
Next in the track, we’ll explore database-backed storage—taking your todo app to SQLite, adding due dates, and learning to query your tasks efficiently. That’s the natural evolution of your app’s architecture and will set you up for syncing and cloud features later.
But first, take a moment to appreciate what you’ve built: a todo app that actually remembers. That’s a huge milestone on your way to becoming a mobile developer.
Practice recap
Extend the todo app to toggle tasks as done (e.g., strikethrough text) and add a delete button. After each change, save the updated list. Test that after closing and reopening the app, your changes persist. Pro tip: print the JSON file contents before and after to see the data flow.
Common mistakes
- Forgetting to create the data directory before writing the file, causing
FileNotFoundErroron some platforms—always useos.makedirs(os.path.dirname(path), exist_ok=True). - Saving the whole list on every tiny change, which can be slow and cause data loss if the app crashes mid-write—consider atomic writes (write to temp, then rename).
- Not handling
JSONDecodeErrorwhen the file is corrupt—your app crashes on startup instead of gracefully starting fresh. - Using a hardcoded file path instead of
user_data_dir, leading to permission issues on mobile platforms.
Variations
- Use SQLite instead of JSON for more complex queries and larger task lists.
- Adopt a library like
tinyDBfor document-oriented persistence with a JSON-like API but more features. - On Android/iOS, you can use platform-specific key-value stores (SharedPreferences/UserDefaults) for simple boolean flags like 'is_first_launch'.
Real-world use cases
- Meeting scheduler app that saves upcoming events locally so they're visible offline and across app restarts.
- Habit tracker that stores daily check-ins and progress history in a local database.
- Fitness app that persists workout logs and user preferences to provide a seamless experience after every launch.
Key takeaways
- Persistence is mandatory for user-generated data—without it, your app is unusable in real life.
- The cycle is load → display → user action → save; always load at startup and save on every mutation.
- JSON files are perfect for simple data models like a todo list; move to SQLite when data grows or needs queries.
- Always make your loader tolerant: handle missing files, corrupt data, and schema changes gracefully.
- Use the platform's app data directory, not hardcoded paths, to avoid permission issues on mobile.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.