Build a command-line app in Python with argparse and SQLite
A complete to-do tool with subcommands, a SQLite database, clear errors and exit codes, using only the standard library.
What you'll build
A small command-line tool teaches most of what makes software hold up: separating storage from interface, handling bad input, and writing errors to the right place. This one manages a to-do list in SQLite, with nothing to install.
What you'll learn
- Turn a short brief into requirements, a data model and a layered design
- Build a command-line interface with argparse, exit codes and clear errors
- Store data with a repository class over SQLite
- Test a whole program by calling its entry point with arguments
What you need
Python functions and classes, and a little SQL. It uses only the standard library, so it runs anywhere with Python 3.
Step 1: Keep storage in one class
TaskRepo is the only code that knows about SQL. Everything else calls its methods, so you could change the database without touching the commands. The ? placeholders let SQLite handle the values, which is what stops SQL injection. ORDER BY CASE sorts by priority in the order that makes sense to people, not alphabetically.
import argparse import sqlite3 import sys PRIORITIES = ["low", "normal", "high"] class TaskRepo: def __init__(self, db): self.db = db self.db.execute(""" CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, priority TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0 ) """) def add(self, title, priority): cur = self.db.execute("INSERT INTO tasks (title, priority) VALUES (?, ?)", (title, priority)) self.db.commit() return cur.lastrowid def list(self, include_done=False): where = "" if include_done else "WHERE done = 0" return self.db.execute(f""" SELECT id, title, priority, done FROM tasks {where} ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, id """).fetchall() def mark_done(self, task_id): cur = self.db.execute("UPDATE tasks SET done = 1 WHERE id = ?", (task_id,)) self.db.commit() return cur.rowcount == 1
Step 2: Define the commands
argparse gives you subcommands, --help and error messages for free. choices rejects a bad priority before your code sees it. main takes its arguments and the repository as parameters, not from sys.argv and a global, which is what makes the whole program easy to test. parse_args exits on bad input, so the try turns that into a normal return value.
def main(argv, repo): parser = argparse.ArgumentParser(prog="tasks") sub = parser.add_subparsers(dest="command", required=True) add = sub.add_parser("add") add.add_argument("title") add.add_argument("--priority", choices=PRIORITIES, default="normal") lst = sub.add_parser("list") lst.add_argument("--all", action="store_true") done = sub.add_parser("done") done.add_argument("id", type=int) try: args = parser.parse_args(argv) except SystemExit as exc: return exc.code
Rather write it yourself?
In the lesson you write this from a starter, in your browser with nothing to install, and checks tell you what's wrong as you go. A free account opens it and all 244 lessons.
Step 3: Handle each command
Each branch does one job and returns an exit code: 0 for success and 1 for failure. Shell scripts rely on those. Errors go to stderr so they don't end up in a file when someone redirects the output. And strip catches a title that's only spaces, which argparse can't.
if args.command == "add": title = args.title.strip() if not title: print("error: title can't be empty", file=sys.stderr) return 1 task_id = repo.add(title, args.priority) print(f"Added task {task_id}: {title}") return 0 if args.command == "list": rows = repo.list(include_done=args.all) if not rows: print("No tasks.") for task_id, title, priority, is_done in rows: mark = "x" if is_done else " " print(f"[{mark}] {task_id} ({priority}) {title}") return 0 if args.command == "done": if not repo.mark_done(args.id): print(f"error: no task {args.id}", file=sys.stderr) return 1 print(f"Done: {args.id}") return 0 return 1
Step 4: Run it
Calling main with a list of arguments is exactly how a test would run it, and it uses a database in memory. High priority comes first, and the finished task shows as done.
repo = TaskRepo(sqlite3.connect(":memory:")) main(["add", "Write report", "--priority", "high"], repo) main(["add", "Buy milk"], repo) main(["done", "2"], repo) main(["list", "--all"], repo)
Added task 1: Write report Added task 2: Buy milk Done: 2 [ ] 1 (high) Write report [x] 2 (normal) Buy milk
To use it as a real tool, open a file instead and pass the command line in:
if __name__ == "__main__": sys.exit(main(sys.argv[1:], TaskRepo(sqlite3.connect("tasks.db"))))
Where to take it next
- Write tests that call
mainwith arguments and check the exit code and output. - Add a
deletecommand, or due dates, which means changing the table. - Package it with
pyproject.tomlsotasksworks as a command.
Mistakes to watch for
- Printing errors to stdout and always exiting with 0.
- Reading sys.argv deep inside the program, which makes it untestable.
- Putting SQL in the command-line code instead of one repository class.
- Skipping requirements and discovering them as bugs.
The complete program
import argparse import sqlite3 import sys PRIORITIES = ["low", "normal", "high"] class TaskRepo: def __init__(self, db): self.db = db self.db.execute(""" CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, priority TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0 ) """) def add(self, title, priority): cur = self.db.execute("INSERT INTO tasks (title, priority) VALUES (?, ?)", (title, priority)) self.db.commit() return cur.lastrowid def list(self, include_done=False): where = "" if include_done else "WHERE done = 0" return self.db.execute(f""" SELECT id, title, priority, done FROM tasks {where} ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, id """).fetchall() def mark_done(self, task_id): cur = self.db.execute("UPDATE tasks SET done = 1 WHERE id = ?", (task_id,)) self.db.commit() return cur.rowcount == 1 def main(argv, repo): parser = argparse.ArgumentParser(prog="tasks") sub = parser.add_subparsers(dest="command", required=True) add = sub.add_parser("add") add.add_argument("title") add.add_argument("--priority", choices=PRIORITIES, default="normal") lst = sub.add_parser("list") lst.add_argument("--all", action="store_true") done = sub.add_parser("done") done.add_argument("id", type=int) try: args = parser.parse_args(argv) except SystemExit as exc: return exc.code if args.command == "add": title = args.title.strip() if not title: print("error: title can't be empty", file=sys.stderr) return 1 task_id = repo.add(title, args.priority) print(f"Added task {task_id}: {title}") return 0 if args.command == "list": rows = repo.list(include_done=args.all) if not rows: print("No tasks.") for task_id, title, priority, is_done in rows: mark = "x" if is_done else " " print(f"[{mark}] {task_id} ({priority}) {title}") return 0 if args.command == "done": if not repo.mark_done(args.id): print(f"error: no task {args.id}", file=sys.stderr) return 1 print(f"Done: {args.id}") return 0 return 1 repo = TaskRepo(sqlite3.connect(":memory:")) main(["add", "Write report", "--priority", "high"], repo) main(["add", "Buy milk"], repo) main(["done", "2"], repo) main(["list", "--all"], repo)
Write it yourself
In the lesson you write this from a starter, in your browser with nothing to install, and checks tell you what's wrong as you go. A free account opens it and all 244 lessons.