Building a Simple User API with FastAPI and SQLite (Python)
Ha! You think you can avoid REST APIs - they’re everywhere in modern web development, and FastAPI & python makes building them ridiculously easy. You’re going to love how quickly you can get a fully functional API server up and running.
In this guide, we’ll build a simple user management API with a SQLite database. It’s the perfect starter project because it covers all the essential concepts you’ll use in bigger applications.
And yeah, this is kinda a tutorial. You have adapt, right?
What We’re Building
We’re creating a REST API that manages users with these fields:
- First name
- Last name
- GitHub handle
Don’t worry if you haven’t built an API before - I’ll walk you through every step, and by the end you’ll have a working server you can actually test and use.
Setting Up Your Environment
Imagine you have already setup your project directory and so on, suing github.
Setup a virtual environment for the project.
First, let’s install what we need:
1pip install fastapi uvicorn sqlite3 pytest httpx
Trust me, this is way easier than setting up a full database server. SQLite is perfect for learning and development because it’s just a file on your computer.
Database Setup and User Model
Let’s start with the foundation - our database model. Create a file called database.py
:
1import sqlite3
2from typing import Optional, List
3from pydantic import BaseModel
4import os
5
6class User(BaseModel):
7 id: Optional[int] = None
8 first_name: str
9 last_name: str
10 email: str
11 github_handle: str
12
13class UserDatabase:
14 def __init__(self, db_path: str = "users.db"):
15 self.db_path = db_path
16 self.init_db()
17
18 def init_db(self):
19 """Create the users table if it doesn't exist"""
20 with sqlite3.connect(self.db_path) as conn:
21 conn.execute("""
22 CREATE TABLE IF NOT EXISTS users (
23 id INTEGER PRIMARY KEY AUTOINCREMENT,
24 first_name TEXT NOT NULL,
25 last_name TEXT NOT NULL,
26 email TEXT UNIQUE NOT NULL,
27 github_handle TEXT UNIQUE NOT NULL
28 )
29 """)
30 conn.commit()
31
32 def create_user(self, user: User) -> User:
33 """Add a new user to the database"""
34 with sqlite3.connect(self.db_path) as conn:
35 cursor = conn.execute("""
36 INSERT INTO users (first_name, last_name, email, github_handle)
37 VALUES (?, ?, ?, ?)
38 """, (user.first_name, user.last_name, user.email, user.github_handle))
39 user.id = cursor.lastrowid
40 conn.commit()
41 return user
42
43 def get_user(self, user_id: int) -> Optional[User]:
44 """Get a user by ID"""
45 with sqlite3.connect(self.db_path) as conn:
46 conn.row_factory = sqlite3.Row
47 cursor = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,))
48 row = cursor.fetchone()
49 if row:
50 return User(**dict(row))
51 return None
52
53 def get_all_users(self) -> List[User]:
54 """Get all users from the database"""
55 with sqlite3.connect(self.db_path) as conn:
56 conn.row_factory = sqlite3.Row
57 cursor = conn.execute("SELECT * FROM users")
58 rows = cursor.fetchall()
59 return [User(**dict(row)) for row in rows]
60
61 def update_user(self, user_id: int, user: User) -> Optional[User]:
62 """Update an existing user"""
63 with sqlite3.connect(self.db_path) as conn:
64 conn.execute("""
65 UPDATE users
66 SET first_name = ?, last_name = ?, email = ?, github_handle = ?
67 WHERE id = ?
68 """, (user.first_name, user.last_name, user.email, user.github_handle, user_id))
69
70 if conn.total_changes == 0:
71 return None
72
73 conn.commit()
74 user.id = user_id
75 return user
76
77 def delete_user(self, user_id: int) -> bool:
78 """Delete a user by ID"""
79 with sqlite3.connect(self.db_path) as conn:
80 conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
81 deleted = conn.total_changes > 0
82 conn.commit()
83 return deleted
See what we did there? (But wait, did you *really read that code? c’mon, go back, do it right.) We created a clean separation between our data model (the User
class) and our database operations (the UserDatabase
class). This makes testing easier and keeps your code organized.
Creating the FastAPI Server
Now for the exciting part - the actual API server! Create main.py
:
1from fastapi import FastAPI, HTTPException
2from typing import List
3from database import User, UserDatabase
4
5app = FastAPI(title="Simple User API", description="A basic user management API", version="1.0.0")
6db = UserDatabase()
7
8@app.get("/")
9def read_root():
10 """Welcome endpoint"""
11 return {"message": "Welcome to the Simple User API!"}
12
13@app.post("/users/", response_model=User)
14def create_user(user: User):
15 """Create a new user"""
16 try:
17 return db.create_user(user)
18 except Exception as e:
19 raise HTTPException(status_code=400, detail=f"Failed to create user: {str(e)}")
20
21@app.get("/users/", response_model=List[User])
22def get_all_users():
23 """Get all users"""
24 return db.get_all_users()
25
26@app.get("/users/{user_id}", response_model=User)
27def get_user(user_id: int):
28 """Get a specific user by ID"""
29 user = db.get_user(user_id)
30 if user is None:
31 raise HTTPException(status_code=404, detail="User not found")
32 return user
33
34@app.put("/users/{user_id}", response_model=User)
35def update_user(user_id: int, user: User):
36 """Update an existing user"""
37 updated_user = db.update_user(user_id, user)
38 if updated_user is None:
39 raise HTTPException(status_code=404, detail="User not found")
40 return updated_user
41
42@app.delete("/users/{user_id}")
43def delete_user(user_id: int):
44 """Delete a user"""
45 if not db.delete_user(user_id):
46 raise HTTPException(status_code=404, detail="User not found")
47 return {"message": "User deleted successfully"}
48
49if __name__ == "__main__":
50 import uvicorn
51 uvicorn.run(app, host="0.0.0.0", port=8000)
This is beautiful, isn’t it? But hey, what are all those @app.
lines, eh? Maybe ask Gabby, or Claude, or even Chad. FastAPI automatically generates API documentation, handles request/response validation, and gives you clean error handling. You get all this functionality with just a few decorators!
Running Your Server
Start your server with:
1uvicorn main:app --reload
The --reload
flag is fantastic for development - it automatically restarts the server when you make code changes. No more manual restarts!
Once it’s running, visit http://localhost:8000/docs
and you’ll see FastAPI’s automatic interactive documentation. It’s like having Postman built right into your API!
Python Tests
Here’s why tests are crucial: they catch bugs before your users do, and they give you confidence when making changes. Create test_api.py
:
1import pytest
2import os
3from fastapi.testclient import TestClient
4from main import app
5from database import UserDatabase
6
7client = TestClient(app)
8
9@pytest.fixture
10def test_db():
11 """Create a test database that gets cleaned up after each test"""
12 test_db_path = "test_users.db"
13 db = UserDatabase(test_db_path)
14 yield db
15 # Cleanup
16 if os.path.exists(test_db_path):
17 os.remove(test_db_path)
18
19def test_root_endpoint():
20 """Test the welcome endpoint"""
21 response = client.get("/")
22 assert response.status_code == 200
23 assert response.json() == {"message": "Welcome to the Simple User API!"}
24
25def test_create_user():
26 """Test creating a new user"""
27 user_data = {
28 "first_name": "John",
29 "last_name": "Doe",
30 "email": "john.doe@example.com",
31 "github_handle": "johndoe"
32 }
33
34 response = client.post("/users/", json=user_data)
35 assert response.status_code == 200
36
37 created_user = response.json()
38 assert created_user["first_name"] == "John"
39 assert created_user["last_name"] == "Doe"
40 assert created_user["email"] == "john.doe@example.com"
41 assert created_user["github_handle"] == "johndoe"
42 assert "id" in created_user
43
44def test_get_user():
45 """Test retrieving a specific user"""
46 # First create a user
47 user_data = {
48 "first_name": "Jane",
49 "last_name": "Smith",
50 "email": "jane.smith@example.com",
51 "github_handle": "janesmith"
52 }
53
54 create_response = client.post("/users/", json=user_data)
55 user_id = create_response.json()["id"]
56
57 # Then retrieve it
58 response = client.get(f"/users/{user_id}")
59 assert response.status_code == 200
60
61 user = response.json()
62 assert user["first_name"] == "Jane"
63 assert user["id"] == user_id
64
65def test_get_all_users():
66 """Test retrieving all users"""
67 # Create a couple of users first
68 users = [
69 {
70 "first_name": "Alice",
71 "last_name": "Johnson",
72 "email": "alice@example.com",
73 "github_handle": "alicejohnson"
74 },
75 {
76 "first_name": "Bob",
77 "last_name": "Wilson",
78 "email": "bob@example.com",
79 "github_handle": "bobwilson"
80 }
81 ]
82
83 for user in users:
84 client.post("/users/", json=user)
85
86 response = client.get("/users/")
87 assert response.status_code == 200
88
89 all_users = response.json()
90 assert len(all_users) >= 2
91
92def test_update_user():
93 """Test updating an existing user"""
94 # Create a user
95 user_data = {
96 "first_name": "Original",
97 "last_name": "Name",
98 "email": "original@example.com",
99 "github_handle": "originalname"
100 }
101
102 create_response = client.post("/users/", json=user_data)
103 user_id = create_response.json()["id"]
104
105 # Update the user
106 updated_data = {
107 "first_name": "Updated",
108 "last_name": "Name",
109 "email": "updated@example.com",
110 "github_handle": "updatedname"
111 }
112
113 response = client.put(f"/users/{user_id}", json=updated_data)
114 assert response.status_code == 200
115
116 updated_user = response.json()
117 assert updated_user["first_name"] == "Updated"
118 assert updated_user["email"] == "updated@example.com"
119
120def test_delete_user():
121 """Test deleting a user"""
122 # Create a user
123 user_data = {
124 "first_name": "ToDelete",
125 "last_name": "User",
126 "email": "delete@example.com",
127 "github_handle": "deleteuser"
128 }
129
130 create_response = client.post("/users/", json=user_data)
131 user_id = create_response.json()["id"]
132
133 # Delete the user
134 response = client.delete(f"/users/{user_id}")
135 assert response.status_code == 200
136 assert response.json()["message"] == "User deleted successfully"
137
138 # Verify it's gone
139 get_response = client.get(f"/users/{user_id}")
140 assert get_response.status_code == 404
141
142def test_user_not_found():
143 """Test handling of non-existent user"""
144 response = client.get("/users/99999")
145 assert response.status_code == 404
146 assert "User not found" in response.json()["detail"]
147
148def test_duplicate_email():
149 """Test that duplicate emails are handled"""
150 user_data = {
151 "first_name": "Test",
152 "last_name": "User",
153 "email": "duplicate@example.com",
154 "github_handle": "testuser1"
155 }
156
157 # Create first user
158 response1 = client.post("/users/", json=user_data)
159 assert response1.status_code == 200
160
161 # Try to create second user with same email
162 user_data["github_handle"] = "testuser2" # Different handle
163 response2 = client.post("/users/", json=user_data)
164 assert response2.status_code == 400 # Should fail due to duplicate email
165
166if __name__ == "__main__":
167 pytest.main(["-v", __file__])
Run your tests with:
1pytest test_api.py -v
These tests cover all the happy paths and some edge cases. Notice how we test both successful operations and error conditions? That’s crucial for building robust APIs.
Why curl is Essential for API Testing
Here’s something that’ll save you tons of debugging time: learning to use curl for API testing. While automated tests are great, curl gives you raw, unfiltered access to your API. Here’s why it’s so valuable:
1. Immediate Feedback
No need to write a test - you can immediately see exactly what your API returns:
1# Test the welcome endpoint
2curl http://localhost:8000/
3
4# Create a new user
5curl -X POST "http://localhost:8000/users/" \
6 -H "Content-Type: application/json" \
7 -d '{
8 "first_name": "John",
9 "last_name": "Doe",
10 "email": "john.doe@example.com",
11 "github_handle": "johndoe"
12 }'
13
14# Get all users
15curl http://localhost:8000/users/
16
17# Get a specific user (replace 1 with actual ID)
18curl http://localhost:8000/users/1
19
20# Update a user
21curl -X PUT "http://localhost:8000/users/1" \
22 -H "Content-Type: application/json" \
23 -d '{
24 "first_name": "John",
25 "last_name": "Smith",
26 "email": "john.smith@example.com",
27 "github_handle": "johnsmith"
28 }'
29
30# Delete a user
31curl -X DELETE http://localhost:8000/users/1
2. Debug HTTP Details
Add -v
(verbose) to see exactly what’s happening:
1curl -v http://localhost:8000/users/
This shows you headers, status codes, and the complete HTTP conversation. Super helpful when things aren’t working as expected.
3. Test Error Conditions
Easily test edge cases:
1# Test non-existent user
2curl http://localhost:8000/users/99999
3
4# Test malformed JSON
5curl -X POST "http://localhost:8000/users/" \
6 -H "Content-Type: application/json" \
7 -d '{"invalid": json}'
8
9# Test missing required fields
10curl -X POST "http://localhost:8000/users/" \
11 -H "Content-Type: application/json" \
12 -d '{"first_name": "John"}'
4. Works Everywhere
curl is available on virtually every system. Your automated tests might not run on a production server, but curl will. It’s perfect for quick production debugging.
5. Documentation and Communication
When you find a bug, you can share the exact curl command that reproduces it. Way better than trying to describe the problem in words.
Pro Tips for API Development
Start Simple: Build one endpoint at a time. Don’t try to create everything at once.
Use FastAPI’s Auto-docs: Visit /docs
on your running server. It’s interactive and lets you test endpoints without curl.
Handle Errors Gracefully: Notice how we use HTTPException
to return proper error codes and messages.
Keep Your Database Logic Separate: See how we put all database operations in a separate class? This makes testing and maintenance much easier.
Test Both Success and Failure: Your tests should cover what happens when things go wrong, not just when they work perfectly.
What’s Next?
You’ve got a solid foundation now! Here are some enhancements to consider:
- Add authentication and authorization
- Implement pagination for large user lists
- Add input validation and sanitization
- Set up logging and monitoring
- Deploy to a cloud platform
The beautiful thing about FastAPI is how easy it makes all of these improvements. You’re not just learning a framework - you’re learning patterns that scale to enterprise applications.
Start with this simple API, get comfortable with the concepts, then gradually add complexity. Six months from now, you’ll be amazed at how much you can build with these foundations!