Skip to content

Fix: Flask Route Returns 404 Not Found

FixDevs · (Updated: )

Part of:  Python Errors

Quick Answer

How to fix Flask routes returning 404 — trailing slash redirect, Blueprint prefix issues, route not registered, debug mode, and common URL rule mistakes.

The Error

A Flask route that should exist returns a 404:

GET /api/users HTTP/1.1
404 NOT FOUND

Or Flask returns a redirect instead of the expected response:

GET /api/users/
301 MOVED PERMANENTLY → /api/users

Or the route works in development but not after deployment. Or a Blueprint route is completely unreachable:

@users_bp.route('/profile')
def profile():
    return jsonify({'user': 'Alice'})

# GET /profile → 404
# GET /users/profile → 404
# No combination works

Why This Happens

Flask 404s come from one of three layers, and identifying which layer rejected the request points straight at the fix. The first layer is Werkzeug’s URL routing — it matches the incoming path against the rules registered in app.url_map. If no rule matches (trailing slash mismatch, typo in the route, prefix not applied), Werkzeug raises NotFound and Flask returns 404. The second layer is module loading — if the file containing the route was never imported (because the application factory forgot to import it, or because gunicorn is pointing at the wrong module), the rule was never registered in the first place. The third layer is deployment — a reverse proxy that strips a path prefix, a WSGI mount point that prepends one, or a static file handler that intercepts the path before Flask sees it.

The reason “the same code works locally but 404s in production” is so common is that the third layer is invisible during local development. Running flask run serves on / with no prefix and no proxy. Putting the same code behind nginx with location /api/ and proxy_pass http://localhost:5000; rewrites the path Flask sees, and a route registered as /api/users will never match because Flask receives /users. The fix is not in your Python code; it is in nginx.

Blueprint nesting adds a fourth wrinkle. Since Flask 2.0, blueprints can be registered on other blueprints, and the URL prefix concatenates: a child blueprint with prefix /v1 registered on a parent with prefix /api produces /api/v1/<rule>. Older code that pre-dates blueprint nesting may double-prefix accidentally.

  • Trailing slash mismatch — Flask has strict URL rules. /users and /users/ are different routes. Flask’s default behavior redirects /users/ to /users (or returns 404 for the inverse), depending on how the route is defined.
  • Blueprint not registered on the app — defining a Blueprint doesn’t make its routes available. You must call app.register_blueprint().
  • Blueprint url_prefix applied unexpectedly — routes defined as /profile in a Blueprint registered with url_prefix='/users' are reachable at /users/profile, not /profile.
  • Route defined after app.run() — Flask registers routes at import time. Any route decorated after app.run() is never registered.
  • Module not imported — if the file containing a route is never imported, Flask never sees the decorator.
  • Method not allowed — the route exists but only handles GET, and you’re sending a POST request. Flask returns 405, not 404, but the behavior feels the same.
  • Static files interfering — a static file at the same path as a route can shadow the route in some configurations.

Version History That Changes the Failure Mode

Flask is one of the longest-lived Python web frameworks, and the routing rules have evolved through several rewrites. The shape of “404 not found” depends heavily on the Flask version in pip show flask.

Flask 0.x (2010–2018). The original flask.Flask API with app.route and the early Blueprint system. Trailing-slash strict matching has been the default since 0.1. Blueprints predated the application factory pattern that is now considered idiomatic. Many tutorials from this era show app.run() at module top level, which conflicts with modern WSGI servers.

Flask 1.0 (April 2018). Stabilized the public API. flask CLI replaced flask-script (flask run instead of python manage.py runserver). The application factory pattern (create_app()) became the documented way to structure apps. Werkzeug routing was rebuilt around werkzeug.routing.Map with cleaner converters.

Flask 1.1 (July 2019). Added flask routes command — the single best 404 debugging tool. If a route does not appear in flask routes output, it was never registered, and no amount of fiddling with url_prefix will help.

Flask 2.0 (May 2021). Added async def view support (requires pip install flask[async]). Added method-specific decorators (@app.get, @app.post, @app.put, @app.delete, @app.patch). Blueprints can now be nested: parent_bp.register_blueprint(child_bp, url_prefix='/v1'). Removed flask.json.JSONEncoder workarounds from earlier versions. Old code using @app.route('/x', methods=['POST']) keeps working but @app.post('/x') is the modern equivalent.

Flask 2.1 / 2.2 (2022). Tightened the static files configuration. app.send_static_file behavior changed for blueprint static folders. Deprecated app.before_first_request in favor of using lifespan-aware patterns. Some url_for edge cases with nested blueprints were fixed — code that returned 404 due to ambiguous endpoint names in Flask 2.0 started resolving in 2.2.

Flask 2.3 (April 2023). Dropped Python 3.7 support. Removed several long-deprecated APIs.

Flask 3.0 (September 2023). Werkzeug 3.0 underneath. Removed flask.Markup, flask.escape, flask.url_quote. Werkzeug’s Rule objects gained the websocket=True parameter for asyncio frameworks layered on top of Flask. The static URL building changed subtly — url_for('static', filename='x.js') resolves the same, but custom static endpoints on blueprints follow new precedence rules.

Werkzeug routing rule evolution. The <converter:variable> syntax has been stable since Werkzeug 0.5 (2010), but new converters arrived over time: path for slash-containing values, uuid for UUIDs, and default for optional segments. Older code that hand-rolls regex matching in the view often misses these and returns 404 from inside the handler rather than letting Werkzeug match correctly.

Static files serving change. In Flask 1.x and earlier, static files were served by a default static endpoint registered on the app. In 2.x+, blueprints can declare their own static_folder, and the resolution order is “blueprint static first, then app static, then 404.” Misconfigured blueprint static folders can shadow app routes.

url_for / blueprint endpoint history. Endpoint names in nested blueprints use a dotted notation: parent.child.profile. Older code that built URLs with url_for('profile') in a nested context now needs url_for('parent.child.profile'). Werkzeug raises BuildError (which Flask surfaces as a 500) rather than 404, but the symptom looks similar from the client side.

If you are reading a Stack Overflow answer from 2014 with app.run(host='0.0.0.0') at module top and no application factory, that code will not 404 in development but will fail in any production WSGI server. Translate it to a create_app() factory before debugging the 404.

Fix 1: Fix Trailing Slash Rules

Flask distinguishes between routes with and without a trailing slash:

# Defined WITHOUT trailing slash
@app.route('/users')
def get_users():
    return 'users'

# GET /users → 200 OK ✓
# GET /users/ → 308 PERMANENT REDIRECT → /users
# Defined WITH trailing slash (directory-like)
@app.route('/users/')
def get_users():
    return 'users'

# GET /users/ → 200 OK ✓
# GET /users → 301 REDIRECT → /users/

Best practice for APIs — omit trailing slashes and set strict_slashes=False:

@app.route('/api/users', strict_slashes=False)
def get_users():
    return jsonify({'users': []})

# Both /api/users and /api/users/ → 200 OK ✓

Or configure globally for all routes:

app = Flask(__name__)
app.url_map.strict_slashes = False  # All routes accept with or without trailing slash

Fix 2: Register the Blueprint

A Blueprint’s routes are only active after the Blueprint is registered with the app:

# users/routes.py
from flask import Blueprint, jsonify

users_bp = Blueprint('users', __name__)

@users_bp.route('/profile')
def profile():
    return jsonify({'user': 'Alice'})
# app.py
from flask import Flask
from users.routes import users_bp  # Import the Blueprint

app = Flask(__name__)

# MUST register the Blueprint — routes are inactive without this
app.register_blueprint(users_bp, url_prefix='/users')

# Now reachable at /users/profile

Verify registered routes:

# List all registered routes — add this temporarily to debug
with app.app_context():
    for rule in app.url_map.iter_rules():
        print(f"{rule.methods} {rule.rule}")
# Or use the Flask CLI
flask routes
# Endpoint          Methods    Rule
# ----------------  ---------  -------------------------
# users.profile     GET, HEAD  /users/profile
# static            GET        /static/<path:filename>

Fix 3: Fix Blueprint url_prefix

The url_prefix in register_blueprint() is prepended to every route in the Blueprint:

# Blueprint defines /profile
@users_bp.route('/profile')
def profile():
    return 'profile'

# Registered with prefix /users
app.register_blueprint(users_bp, url_prefix='/users')

# Route is at /users/profile — NOT /profile

You can also set the prefix on the Blueprint itself:

users_bp = Blueprint('users', __name__, url_prefix='/users')

@users_bp.route('/profile')  # Full path: /users/profile
def profile():
    return 'profile'

# Register without a prefix (Blueprint already has one)
app.register_blueprint(users_bp)

Don’t double-prefix:

# Wrong — prefix on both Blueprint and register_blueprint
users_bp = Blueprint('users', __name__, url_prefix='/users')

@users_bp.route('/profile')
def profile():
    return 'profile'

# Registered with ANOTHER prefix — full path: /api/users/profile (double prefix)
app.register_blueprint(users_bp, url_prefix='/api/users')

Fix 4: Ensure Modules Are Imported

Flask only knows about routes that have been decorated and imported. If a module with route definitions is never imported, those routes don’t exist:

# app.py — WRONG: routes never imported
from flask import Flask

app = Flask(__name__)

if __name__ == '__main__':
    app.run()

# users.py exists with routes but is never imported → 404
# app.py — CORRECT: import routes after creating app
from flask import Flask

app = Flask(__name__)

# Import routes to register them (even if you don't use the module directly)
from . import users   # or: import users

if __name__ == '__main__':
    app.run()

Application factory pattern — import routes inside the factory:

def create_app():
    app = Flask(__name__)

    # Register Blueprints inside the factory
    from .users import users_bp
    from .posts import posts_bp

    app.register_blueprint(users_bp, url_prefix='/users')
    app.register_blueprint(posts_bp, url_prefix='/posts')

    return app

Fix 5: Fix Method Not Allowed (405 vs 404)

By default, Flask routes only accept GET (and HEAD). Sending a POST to a GET-only route returns 405, which can look like a 404:

# Only handles GET
@app.route('/api/users')
def get_users():
    return jsonify([])

# POST /api/users → 405 Method Not Allowed
# Handle multiple methods explicitly
@app.route('/api/users', methods=['GET', 'POST'])
def users():
    if request.method == 'GET':
        return jsonify([])
    elif request.method == 'POST':
        data = request.json
        return jsonify(data), 201

Or use method-specific decorators (Flask 2.0+):

@app.get('/api/users')
def get_users():
    return jsonify([])

@app.post('/api/users')
def create_user():
    data = request.json
    return jsonify(data), 201

Check what methods a route accepts:

flask routes
# Endpoint     Methods          Rule
# -----------  ---------------  ------------
# get_users    GET, HEAD        /api/users

Fix 6: Debug Route Registration at Runtime

Use Flask’s built-in tools to see exactly what routes are registered:

# In your app or a debug script
from app import app

with app.test_request_context():
    print(app.url_map)
# Test a specific URL
from app import app

with app.test_request_context('/api/users'):
    from flask import request
    print(request.path)  # /api/users

# Match a URL to a route
from werkzeug.routing import Map
adapter = app.url_map.bind('localhost')
try:
    endpoint, args = adapter.match('/api/users', method='GET')
    print(f"Route: {endpoint}, Args: {args}")
except Exception as e:
    print(f"No match: {e}")

Enable debug mode to get detailed error pages:

app = Flask(__name__)
app.debug = True  # Shows full traceback on 500, detailed routing info

# Or via environment variable
# FLASK_DEBUG=1 flask run

Warning: Never run Flask with debug=True in production. Debug mode enables the interactive debugger, which allows arbitrary code execution if accessed by an attacker.

Fix 7: Fix Routes After Deployment

Routes that work locally but return 404 after deployment are usually caused by:

WSGI server path prefix issues:

# If your app is mounted at /myapp on the server (not /)
# Requests arrive at /myapp/api/users but Flask sees /api/users
# Use APPLICATION_ROOT or ProxyFix middleware

from werkzeug.middleware.proxy_fix import ProxyFix

app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_prefix=1)

Gunicorn with a different module path:

# Correct — specify module:app
gunicorn "myapp:create_app()"  # For application factory
gunicorn myapp.app:app         # For direct app object

# Wrong — module not found, Flask defaults to 404 for all routes
gunicorn myapp:application     # If variable is named 'app', not 'application'

Nginx stripping the path prefix:

# Wrong — strips /api prefix before passing to Flask
location /api {
    proxy_pass http://localhost:5000;
    # Request for /api/users becomes /users in Flask → 404
}

# Correct — preserve the full path
location /api {
    proxy_pass http://localhost:5000/api;
}

# Or use a trailing slash on both sides
location /api/ {
    proxy_pass http://localhost:5000/api/;
}

Still Not Working?

Print all routes at startup:

@app.before_request
def log_request():
    app.logger.debug(f"Request: {request.method} {request.path}")

Check if the route is being shadowed by a static file:

# Flask serves static files from /static by default
# A file at static/api/users.html would NOT shadow /api/users routes
# But misconfigured web servers can serve static files for all paths

ls app/static/

Try the route with Flask’s test client to bypass network issues:

def test_route():
    with app.test_client() as client:
        response = client.get('/api/users')
        print(response.status_code)   # Should be 200, not 404
        print(response.data)

Check for nested blueprint endpoint names. If you registered a blueprint inside another blueprint, the endpoint name is dotted: parent.child.view_function, not just view_function. Calling url_for('view_function') from anywhere outside that nested context raises BuildError and the redirect target ends up as a 404 page. Run flask routes and use the exact endpoint name shown.

Check that gunicorn workers loaded the same module as your tests. Misconfigured gunicorn command lines like gunicorn app:application when the variable is actually app cause every request to 404 because gunicorn fell back to a stub WSGI app. The gunicorn --check-config myapp.app:app flag validates the module path without starting workers.

Check the APPLICATION_ROOT config when behind a path prefix. If your Flask app is mounted at /myapp on a shared server, set APPLICATION_ROOT = '/myapp' and use werkzeug.middleware.dispatcher.DispatcherMiddleware or ProxyFix to honor the prefix. Without it, url_for builds URLs without the prefix and clients hit a 404 when they follow generated links.

For related issues, see Fix: FastAPI 422 Unprocessable Entity, Fix: Express Cannot GET Route, Fix: Flask CORS Not Working, and Fix: Django OperationalError No Such Table.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles