top of page

FastAPI: The Web Framework That Feels Like Python

Writer: Mic
Mic
May 19
6 min read

Updated: May 26

Building a web API is straight-forward, but there are plenty of points to make small mistakes. We want to build one that validates input, generates docs, handles async properly, and doesn’t make you fight the framework is not.

FastAPI sits in an interesting place. On the one hand, it is not a minimal library like Flask. But on the other hand, it is also not a batteries-included giant like Django. It is opinionated about the problems that waste time, like input validation, serialisation, documentation, and hands you Python’s type system to solve them.

This is the first of two posts. Here we look at the fundamentals. In the next one, we compose APIs that do real work: authentication, background tasks, database integration, and proper error handling.

Apparently for ChatGPT, FastAPI is a cute little smiling conveyer belt...

The Core Idea is Types

Most web frameworks make you write the same code over and over:

  • Parse the request body

  • Check if fields exist

  • Validate their types

  • Return a 400 if something is wrong

  • Document what you just wrote

FastAPI essentially reverses the usual API-building workflow. Instead of writing a route, then separately writing validation rules, then separately writing documentation, you declare what the function expects using normal Python type hints. FastAPI uses those type hints to validate incoming requests, convert data into Python objects, return JSON responses, and generate interactive API documentation automatically. The official FastAPI documentation describes this as one of the main ideas of the framework: declare parameter and body types once, and FastAPI handles validation, conversion, OpenAPI documentation, and interactive docs from that declaration.

Here is the simplest possible API:

Here the line app = FastAPI() creates the FastAPI application object. The decorator @app.get("/") tells the system: if someone sends a get request to the root path, run this function. The function then returns a simple Python dictionary, this will automatically be serialised as JSON. So the client should receive

{
  "message": "Hello"
}

For this to work we have to start a server, in this case we use the development server to host this locally. You can either start this via

fastapi dev main.py

or you can start the uvicorn web server directly via

uvicorn main:app --reload

Uvicorn expects the application path in the form module:object, so main:app means “import app from main.py.”

To access this quickly, open a browser and the address

http://127.0.0.1:8000

and you will see

{"message":"Hello"}

Or alternatively you can call it directly from another terminal, using

curl http://127.0.0.1:8000

Now we have a simplistic type of JSON API hosted locally. You can also access a documentation of your API by opening

http://127.0.0.1:8000/docs

in the browser.


Request Models with Pydantic

The real power shows up when you need structured input. Instead of manually parsing JSON. We make another short example, but this time we add a Pydantic Model to it.

In case a request comes here, we will only return if we successfully performed the following:

  • Validated username length

  • Checked that email is actually an email

  • Ensured age is between 18 and 120

  • Set newsletter to False if not provided

  • Returned a 422 with detailed errors if anything failed

  • Added all of this to the auto-generated docs

Now if we, send a bad POST request:

curl -X POST http://127.0.0.1:8000/users/ -H "Content-Type: application/json" -d '{"username": "ab", "email": "not-an-email", "age": 15}'

We get back:

{
  "detail": [
	{
	  "type":"string_too_short",
	  "loc":["body","username"],
	  "msg":"String should have at least 3 characters",
   	  "input":"ab",
	  "ctx":{"min_length":3}
	},
	{
	  "type":"value_error",
	  "loc":["body","email"],
      "msg":"value is not a valid email ...",
	  "input":"not-an-email",
	  "ctx":{"reason":"An email address must have an @-sign."}
	},
	{
	  "type":"greater_than_equal",
	  "loc":["body","age"],
      "msg":"Input should be greater than or equal to 18",
      "input":15,
      "ctx":{"ge":18}
    }
  ]
}

We did not need to write any of the error handling code and we get a detailed explanation for each location. We see what the input was for each location that had an error, what type of error we have, we get a message explaining the error and the code part that failed to be validated. In the terminal where you have the local web server running, you will also see the error codes for each request.

In addition you can open the local documentation:

http://127.0.0.1:8000/docs#/

and you get a documentation in which you can also easily test requests without the need for the terminal.


Parameters: The Right Defaults

FastAPI infers what you want from the function signature. If we look at the following example

Then FastAPI infers the following:

  • item_id comes from the URL path, for example: /items/16

  • Everything else comes from query strings: /items/16?q=search&skip=5

This is also fully documented under

http://127.0.0.1:8000/docs#/

There you will see a beautiful list of all parameters, including whether they go into the path or are a query. Especially note that type conversion happens automatically and optional parameters work as expected. For example we can easily leave out any of the queries without problem, but if we leave out the path parameter, we get a 404 error. Or if we request /items/not-a-number, we get:

{
  "detail": [
    {
      "type":"int_parsing",
      "loc":["path","item_id"],
      "msg": "Input should be a valid integer...",
      "input":"not-a-number"
    }
  ]
}

Depending on your version, this might look slightly different. But the important part is that nothing of this had to be handled by us, it is all provided by FastAPI.


Responses: Control What Goes Out

Of course, input validation is only half the problem. The other half is to make sure that output serialisation happens as we want it. So for this we look at the following:

Imagine that our input is a user using the UserCreate model we used above. For the user in the database we save the username and the email, as usual we only do that if the user parameter was validated successfully. We also add a password and a date at which it is created. As this is only a simulated situation we of course skip the real saving of user_in_db in the database. We also return it, but here is the catch: the response_model is only UserResponse, which is missing the password, so even though we return a UserInDB object with a password hash, FastAPI only serialises the fields in UserResponse. The password never leaves the server.

This is also invaluable for refactoring safety. You can change internal models without breaking the API contract, assuming of course that you are not missing out on anything that the response model needs.


Dependency Injection

Most frameworks make you choose between global state and passing arguments everywhere. FastAPI has a third option: dependencies.

Here is a common pattern, getting the current user from a token:

Now every endpoint that needs the current user can declare

user: dict = Depends(get_current_user)

and FastAPI will call get_current_user before the function runs and then passes the result of that to the endpoint. If you would run the above local server and request

curl http://127.0.0.1:8000/me

You will get the expected error message with a 422 error code, since the header is missing. If you instead request

curl -H "Authorization: Bearer wrong_token" / http://127.0.0.1:8000/me

You will get a 401 error, since the token is invalid. If instead you change the token to the correct one, you get the expected answer.

Of course, dependencies can depend on other dependencies. So a construction like this

is perfectly fine. The framework builds the dependency graph and executes it in the right order. You can compose behaviours and it simply works. Of course in a real code you should raise appropriate exceptions to make sure you know what breaks.


Async: Use It When You Need It

FastAPI supports both sync and async handlers. You choose based on what you are doing.

The framework uses different execution models automatically. Here sync functions run in a thread pool, wile async functions run on the event loop. One our end, we only need to change the declaration of the function.

One should note here, that dependencies can cross between sync and async. So for example you can make an async function depend on a sync one. But of course this will easily mess up everything, for example if your async function depends on a sync function that makes the database query, then this will block the event loop.


When NOT to Use FastAPI

FastAPI is excellent for APIs. It is not a full-stack framework.

Skip it if you need:

  • Server-rendered HTML templates (use Django or Flask with Jinja2)

  • Built-in admin panels (Django does this better)

  • Opinionated project structure (Django again)

  • Minimal dependencies (Flask is lighter)

Also consider:

  • If your team does not use type hints, most the value disappears

  • If you are building a GraphQL API, Strawberry or Ariadne are better fits

  • If you need WebSockets beyond simple cases, Starlette (FastAPI’s foundation) gives you more control

FastAPI shines when:

  • You are building a JSON API

  • You want auto-generated docs

  • You care about input validation

  • You use Python 3.7+ with type hints

  • You might need async later

We did not cover here how to make the API available outside your local server. This depends on your architecture and is better done by going into the FastAPI documentation.


What Comes Next

This post covered the foundation: how FastAPI uses types to eliminate boilerplate. In the next post, we build real applications:

  • Proper authentication with JWT

  • Background tasks and worker queues

  • Database integration with SQLAlchemy

  • Error handling that makes sense

  • Testing APIs without pain

The idea is: declare what you want, let the framework handle the mechanics. That philosophy extends to everything we cover next.


Further Reading

Comments


bottom of page