Maintenance

Site is under maintenance — quizzes are still available.

Go to quizzes

Build a To-Do List App

Learn to build a simple to-do list application in this hands-on Python tutorial – step-by-step guide with code examples and troubleshooting tips.

Focus: build a simple to-do list application

Sponsored

Building your own to-do list application is a classic rite of passage for any Python developer. It's the perfect project to move beyond isolated syntax exercises and start combining Python's core features — input handling, data storage, conditional logic, and loops — into a functional tool you can actually use. By the end of this lesson, you won't just understand the pieces; you'll have a working, command-line to-do list that you can run, modify, and even extend into a web app someday.

The problem this lesson solves

Most Python tutorials teach you individual concepts in a vacuum: for loops here, if statements there, file I/O somewhere else. The problem is, you rarely see how they fit together to solve a real, everyday problem. A to-do list forces you to make design decisions: how do you store tasks? How does a user add, view, and complete a task? How do you prevent the program from crashing on unexpected input?

Without a guided project, beginners often get stuck on architecture ("Should I use a list or a dictionary?") or lose motivation halfway through. This lesson provides a structured, repeatable blueprint so you can build a simple to-do list application with confidence and then use it as a foundation for more complex projects.

Core concept / mental model

Think of your to-do list as a digital notebook with a strict set of rules. The notebook has a cover (the program's menu), pages for each task, and a way to flip through them. In Python, we model this with:

  • A list of dictionaries — each task is a dictionary with keys like "id", "task", and "done".
  • A while loop that keeps the program running until the user says "quit."
  • Functions that encapsulate each action (add, view, mark done, delete).
  • Conditional statements to handle user choices.

Pro tip: The key mental shift is from reacting to user input (a script that runs once) to managing a state (a program that maintains data across multiple interactions). This is your first step toward building stateful applications.

How it works step by step

The main menu loop

The program starts by showing a menu of actions. This is the "brain" — a while True loop that keeps asking for commands until the user enters "5" (quit).

Representing tasks

Each task is a dictionary like this:

todo_list = []
task = {"id": 1, "task": "Learn Python lists", "done": False}

We store these dictionaries in a list. The id helps us identify tasks uniquely (since task names could be duplicated), and done is a Boolean flag.

The four core actions

  1. Add a task — Append a new dictionary to the list. Auto-increment the id based on the current length or a counter.
  2. View tasks — Loop through the list and print each task, optionally filtering by done status.
  3. Mark as done — Find a task by id, set "done": True.
  4. Delete a task — Find a task by id and remove it from the list with pop() or list comprehension.

Input validation

Always wrap user input in a try/except block when expecting a number (like task ID). Invalid input should not crash the program — it should display a friendly error and show the menu again.

try:
    choice = int(input("> "))
except ValueError:
    print("Please enter a number.")
    continue

Hands-on walkthrough

Let's build the application! We'll write a single Python file, todo.py, and run it from the terminal.

Step 1: Define the data structure and global variables

# Global list to store tasks
todo_list = []
next_id = 1  # Auto-incrementing ID

Step 2: Define helper functions

def show_menu():
    print("\n--- TO-DO LIST ---")
    print("1. Add a task")
    print("2. View all tasks")
    print("3. Mark a task as done")
    print("4. Delete a task")
    print("5. Quit")

def add_task():
    global next_id
    task_name = input("Enter the task: ").strip()
    if not task_name:
        print("Task cannot be empty.")
        return
    new_task = {"id": next_id, "task": task_name, "done": False}
    todo_list.append(new_task)
    next_id += 1
    print(f"Added task: {task_name}")

def view_tasks():
    if not todo_list:
        print("No tasks yet.")
        return
    print("\nYour tasks:")
    for task in todo_list:
        status = "[X]" if task["done"] else "[ ]"
        print(f"{task['id']}. {status} {task['task']}")

def mark_done():
    view_tasks()
    try:
        task_id = int(input("Enter task ID to mark as done: "))
    except ValueError:
        print("Invalid ID.")
        return
    for task in todo_list:
        if task["id"] == task_id:
            task["done"] = True
            print(f"Task {task_id} marked as done.")
            return
    print(f"Task with ID {task_id} not found.")

def delete_task():
    view_tasks()
    try:
        task_id = int(input("Enter task ID to delete: "))
    except ValueError:
        print("Invalid ID.")
        return
    for i, task in enumerate(todo_list):
        if task["id"] == task_id:
            del todo_list[i]
            print(f"Deleted task {task_id}.")
            return
    print(f"Task with ID {task_id} not found.")

Step 3: The main loop

def main():
    while True:
        show_menu()
        try:
            choice = int(input("> "))
        except ValueError:
            print("Please enter a number 1–5.")
            continue
        if choice == 1:
            add_task()
        elif choice == 2:
            view_tasks()
        elif choice == 3:
            mark_done()
        elif choice == 4:
            delete_task()
        elif choice == 5:
            print("Goodbye!")
            break
        else:
            print("Invalid choice. Enter 1–5.")

if __name__ == "__main__":
    main()

Step 4: Run it

Save the file and run from your terminal:

python todo.py

You'll see:

--- TO-DO LIST ---
1. Add a task
2. View all tasks
3. Mark a task as done
4. Delete a task
5. Quit
> 1
Enter the task: Buy groceries
Added task: Buy groceries
> 2

Your tasks:
1. [ ] Buy groceries
> 3

Your tasks:
1. [ ] Buy groceries
Enter task ID to mark as done: 1
Task 1 marked as done.
> 2

Your tasks:
1. [X] Buy groceries
> 5
Goodbye!

Compare options / when to choose what

Approach Pros Cons Best for
Global list + functions Simple, no extra libraries No persistence (tasks lost on restart) Quick scripts, learning
File-based persistence (JSON) Survives restarts, readable Requires file I/O logic Single-user local use
Database (SQLite) Scalable, concurrent access More setup, learning curve Multi-user or web apps
Object-Oriented (class TodoList) Reusable, testable Overkill for tiny projects Larger projects

For this lesson, the global list is perfect. But in the next lesson, we'll upgrade to JSON file persistence so your tasks survive a program restart.

Troubleshooting & edge cases

User enters non-numeric choice

--- TO-DO LIST ---
1. Add a task
> abc
Please enter a number 1–5.

Our try/except handles it gracefully.

Task ID not found

If the user enters an ID that doesn't exist (e.g., after deleting a task), the loop returns a clear error without crashing.

Empty task name

The add_task() function checks for empty strings with .strip(). This prevents adding blank entries.

Multiple tasks with same name

Because we use a unique id for each task, duplicate names are fine. Operations like mark-done and delete rely on id, not the task text.

Forgetting global keyword

def add_task():
    next_id += 1  # UnboundLocalError!

Always declare global next_id inside the function if you intend to modify the variable. This is a common newbie trap.

Pro tip: Use global sparingly. In larger applications, you'd wrap everything in a class to avoid globals entirely.

What you learned & what's next

You've just built a complete, interactive to-do list application from scratch! You now understand:

  • How to combine loops, conditionals, lists, dictionaries, and functions into a coherent program.
  • How to handle user input and validate it without crashing.
  • How to design a menu-driven interface.
  • How to model a "state" (the task list) and mutate it through user actions.

This is a foundational skill for any backend or full-stack Python developer.

What's next: In the next lesson, we'll enhance this to-do list by adding data persistence using JSON files — so your tasks live on even when you close the program. You'll learn to read and write structured data, handle file errors, and think about data integrity.

For now, run your program, add a few tasks, mark them done, delete some — break it on purpose! The best way to learn is to tweak the code and see what happens. Try adding a filter view that only shows incomplete tasks, or a priority field. Happy coding!

Practice recap

Practice challenge: Add a new menu option 6. Show incomplete tasks that prints only tasks where 'done' is False. You'll need to write a new function view_incomplete() and add an elif branch in main(). Test it with a mix of complete and incomplete tasks. This exercise reinforces conditionals inside loops and user-defined functions.

Common mistakes

  • Forgetting to declare global next_id inside your functions, which raises UnboundLocalError.
  • Using a break statement inside a for loop that searches for a task ID, but forgetting to return after finding it — the loop might print 'not found' after processing.
  • Not stripping whitespace from user input, leading to tasks with leading/trailing spaces that look identical but are technically different.
  • Assuming the user will always enter a valid integer for menu choices — wrapping int(input()) in a try/except is essential.

Variations

  1. Instead of a flat list of dictionaries, you could define a Task class using dataclasses for cleaner, more readable code with type hints.
  2. Use an infinite loop with a match-case statement (Python 3.10+) to handle menu choices, which can be more readable than a chain of elif.
  3. Implement a 'pin' feature that lets users sort or filter tasks by priority (e.g., high, medium, low) by adding a "priority" key to each task dictionary.

Real-world use cases

  • A simple CLI tool for tracking personal daily tasks, like a grocery list or reading list, without needing a network connection.
  • A lightweight bug-tracker prototype where each 'task' represents an issue with a 'done' flag for resolved status — useful for small team sprints.
  • A scheduling assistant that reads tasks from a file and prints a daily agenda, integrated into a shell startup script (e.g., .bashrc).

Key takeaways

  • A to-do list application combines lists of dictionaries, a main while loop, input validation, and functions into a cohesive program.
  • Using a unique id per task prevents ambiguity when deleting or marking tasks, even if names are duplicates.
  • Always wrap int(input()) in a try/except ValueError to handle non-numeric input gracefully.
  • The global keyword is required when modifying a global variable (like next_id) inside a function — but consider using a class for larger projects.
  • A modular design with one function per action makes the code easy to extend, debug, and test.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.