How to Build a Text Analysis Web App in Python With Gradio
Learn how to turn a plain Python function into a working, shareable web app using Gradio's Interface and Blocks, complete with tabs, session state, and error handling.
Gradio is a Python library that turns an ordinary Python function into a working web page: a text box, a button, and a result, all without writing a single line of HTML, CSS, or JavaScript. If you have ever built something useful in Python, whether it is a machine learning model, a data-cleaning script, or a small AI agent, and then struggled to let a non-programmer actually use it, Gradio solves exactly that problem. By the end of this tutorial you will have built, tested, and personally run a small two-tab web application (a text analyzer and a text-case converter) that remembers what you typed earlier in the same session, rejects bad input with a clear error instead of crashing, and is covered by an automated test suite you run yourself.
Table Of Content
- What You Will Build, and the Two Ideas Behind Gradio
- Prerequisites
- Step 1: Set Up an Isolated Project
- Step 2: Your First App With gr.Interface
- Step 3: Returning More Than One Value
- Gotcha: a mismatched output count does not always raise an error
- Step 4: Custom Layouts With gr.Blocks
- Verifying it from the command line with the Gradio Python client
- Step 5: Remembering Things With Session State
- Gotcha: gr.State values must be deep-copyable
- Step 6: Rejecting Bad Input on Purpose
- Step 7: Verify Everything With an Automated Test Suite
- Common Mistakes and Gotchas Recap
- Next Steps: Sharing Your App With Other People
- A temporary public link with share=True
- A permanent home on Hugging Face Spaces
- Confirm It All Works, End to End
- Where to go from here
This matters because Gradio has become the default way researchers, data scientists, and AI teams share a working demo of a model or an agent. Understanding its two core building blocks, Interface for quick one-function demos and Blocks for anything with more than one screen or moving part, is a skill that transfers directly to the countless Gradio-based tools you will run into on Hugging Face Spaces and inside AI products.
What You Will Build, and the Two Ideas Behind Gradio
Gradio’s own documentation describes the library plainly: it is “an open-source Python package that allows you to quickly build a demo or web application for your machine learning model, API, or any arbitrary Python function,” with “no JavaScript, CSS, or web hosting experience needed.” That last part is the whole point. You write a normal Python function; Gradio builds the web page around it.
There are two ways to build with Gradio, and this tutorial teaches both because you will need both:
- gr.Interface: the fast path. Give it a function, describe its inputs and outputs, and Gradio auto-generates a complete page (input widgets, a submit button, output widgets) for you. Best for a single function with a simple, linear flow.
- gr.Blocks: the flexible path. You place components yourself (in rows, columns, and tabs) and wire up exactly which button click runs which function and which outputs it updates. Best for anything with more than one tool, custom layout, or values that need to persist between interactions.
You will start with Interface, hit its limits on purpose, and then rebuild the same idea with Blocks so the tradeoff is concrete rather than theoretical.
Prerequisites
- Windows, macOS, or Linux. This tutorial was written and tested on Windows; every command has a macOS/Linux equivalent noted where it differs.
- Python 3.10 or newer installed. This tutorial was tested against Python 3.13.14; Gradio 6.27.0 (the current release on PyPI at the time of writing) requires Python 3.10+.
pip, which ships with Python.- Basic comfort writing a Python function and running a script from a terminal. No prior web development, HTML, CSS, or JavaScript knowledge is required.
- About 45 to 60 minutes.
Step 1: Set Up an Isolated Project
Always install a library like Gradio into a project-specific virtual environment rather than your system Python. A virtual environment is a self-contained folder holding its own Python interpreter and packages, so upgrading or breaking one project’s dependencies never touches another project. Create one now:
mkdir word-lab
cd word-lab
python -m venv venv
Activate it. The command differs by platform:
venv\Scripts\activate # Windows (Command Prompt or PowerShell)
source venv/bin/activate # macOS or Linux
Your terminal prompt should now show (venv) at the start of the line. Every pip install and python command from here on assumes that prefix is present, meaning you are running the virtual environment’s Python, not your system one.
Install Gradio and pytest (you will use pytest in the last step to verify your logic automatically):
pip install gradio pytest
Expect the last line of a long dependency-resolution log to look like this:
Successfully installed gradio-6.27.0
Confirm it imported correctly and check the version:
python -c "import gradio; print(gradio.__version__)"
6.27.0
Common mistake: if that command instead prints a much older version, or raises ModuleNotFoundError, your terminal is very likely still pointing at a different Python installation than the one where you ran pip install. Re-activate the virtual environment and try again before moving on.
Step 2: Your First App With gr.Interface
Create a file named app.py with a plain Python function that has nothing Gradio-specific about it yet:
def analyze_text(text):
word_count = len(text.split())
char_count = len(text)
return f"{word_count} words, {char_count} characters"
Now wrap it in a gr.Interface:
import gradio as gr
def analyze_text(text):
word_count = len(text.split())
char_count = len(text)
return f"{word_count} words, {char_count} characters"
demo = gr.Interface(
fn=analyze_text,
inputs="text",
outputs="text",
title="Word Lab",
)
if __name__ == "__main__":
demo.launch()
Three arguments do all the work here, and it is worth pausing on each one, because every Gradio app you will ever build is a variation on this same idea:
- fn: the Python function Gradio should call every time a user submits input. Gradio never modifies this function; it only calls it.
- inputs: what kind of widget should collect each argument to
fn. The string"text"is shorthand for a single-line text box. You will use more specific component objects, likegr.Textbox(lines=4), starting in the next step. - outputs: what kind of widget should display each return value from
fn. One string in, one string out, so one text input and one text output.
Run it:
python app.py
Gradio prints two lines telling you where the app is running:
* Running on local URL: http://127.0.0.1:7860
* To create a public link, set `share=True` in `launch()`.
Open that URL in a browser. Type a sentence into the text box, click Submit, and you should see something like 9 words, 43 characters appear on the right. That confirms the whole loop: browser input to your Python function to browser output, with zero frontend code written by you.
Stop the server with Ctrl+C in the terminal before moving to the next step. Gradio’s launch() call blocks the terminal (it keeps running until you interrupt it), which is expected: it is a real, running web server.
Common mistake: if launch() raises OSError: Cannot find empty port in range: 7860-7860, a Gradio server from an earlier run (yours or another project’s) is still alive and holding that port. On Windows, find and stop it with:
netstat -ano | findstr :7860
taskkill /PID <the_process_id_from_the_previous_command> /F
On macOS or Linux, the equivalent is lsof -i :7860 to find the process ID, then kill <pid>. Alternatively, sidestep the whole problem by passing a specific port yourself: demo.launch(server_port=7861).
Step 3: Returning More Than One Value
Real functions usually produce more than one useful number. Extend analyze_text to also report the most frequent word, and match the outputs list to the new return values, in the same order:
import gradio as gr
from collections import Counter
def analyze_text(text):
words = text.split()
word_count = len(words)
char_count = len(text)
counts = Counter(w.strip(".,!?;:").lower() for w in words)
most_common, freq = counts.most_common(1)[0]
return word_count, char_count, f"{most_common} ({freq}x)"
demo = gr.Interface(
fn=analyze_text,
inputs=gr.Textbox(label="Text to analyze", lines=4),
outputs=[
gr.Number(label="Word count"),
gr.Number(label="Character count"),
gr.Textbox(label="Most common word"),
],
title="Word Lab",
)
if __name__ == "__main__":
demo.launch()
Run it and submit “The quick brown fox jumps over the lazy dog. The dog barks.” Calling the underlying function directly (which is exactly what Gradio does behind the scenes) confirms the real result:
>>> analyze_text("The quick brown fox jumps over the lazy dog. The dog barks.")
(12, 59, 'the (3x)')
The tuple position matters. word_count is returned first and maps to the first item in outputs (the first gr.Number); char_count maps to the second; the formatted string maps to the gr.Textbox. Get the order wrong and Gradio will not complain, it will just put the wrong number in the wrong box.
Gotcha: a mismatched output count does not always raise an error
It is tempting to assume Gradio will immediately fail loudly if your function’s return count does not match your outputs list. It does not always. To see this for real, build a function that returns two values but wire it to only one output component:
import gradio as gr
def to_upper(text):
return text.upper(), len(text)
with gr.Blocks() as demo:
text_in = gr.Textbox(label="Text")
convert_btn = gr.Button("Convert")
text_out = gr.Textbox(label="Result")
convert_btn.click(fn=to_upper, inputs=text_in, outputs=text_out)
demo.launch()
Submitting hello world through this app does not crash. It silently renders this into the single output box:
('HELLO WORLD', 11)
Gradio handed the whole tuple to the one Textbox component you gave it, and Textbox simply converts whatever it receives to a string. The bug does not look like a bug: it looks like oddly formatted output. Whenever a Gradio app displays something that looks like a raw Python tuple or object printed as text, count your return values against your outputs list before checking anything else.
Step 4: Custom Layouts With gr.Blocks
Interface is excellent for one function, but this project needs two independent tools (an analyzer and a case converter) in the same app, arranged the way you choose. That is exactly what gr.Blocks is for: instead of describing your function and letting Gradio build the page, you place the components yourself and connect them explicitly with event listeners like .click().
Rebuild the app with two tabs. Notice the corrected version of the case converter, now returning exactly one value to exactly one output:
import gradio as gr
from collections import Counter
def analyze_text(text):
words = text.split()
word_count = len(words)
char_count = len(text)
counts = Counter(w.strip(".,!?;:").lower() for w in words)
most_common, freq = counts.most_common(1)[0]
return word_count, char_count, f"{most_common} ({freq}x)"
def convert_case(text, mode):
if mode == "UPPERCASE":
return text.upper()
if mode == "lowercase":
return text.lower()
return text.title()
with gr.Blocks(title="Word Lab") as demo:
gr.Markdown("# Word Lab")
with gr.Tab("Analyzer"):
with gr.Row():
with gr.Column():
analyze_input = gr.Textbox(label="Text to analyze", lines=4)
analyze_btn = gr.Button("Analyze", variant="primary")
with gr.Column():
word_count_out = gr.Number(label="Word count")
char_count_out = gr.Number(label="Character count")
common_word_out = gr.Textbox(label="Most common word")
analyze_btn.click(
fn=analyze_text,
inputs=analyze_input,
outputs=[word_count_out, char_count_out, common_word_out],
)
with gr.Tab("Case Converter"):
convert_input = gr.Textbox(label="Text")
mode = gr.Radio(["UPPERCASE", "lowercase", "Title Case"], value="UPPERCASE", label="Mode")
convert_btn = gr.Button("Convert")
convert_output = gr.Textbox(label="Result")
convert_btn.click(fn=convert_case, inputs=[convert_input, mode], outputs=convert_output)
if __name__ == "__main__":
demo.launch()
Four new ideas appear in that code, all specific to Blocks:
- gr.Row / gr.Column: control layout direction. Anything inside a
with gr.Row():block sits side by side; anything insidewith gr.Column():stacks vertically. - gr.Tab: groups components under a clickable tab label, so unrelated tools do not have to share one screen.
- .click(fn, inputs, outputs): an event listener. Unlike
Interface, which wires everything for you automatically, inBlocksyou explicitly say “when this button is clicked, call this function with these components as arguments, and send the return values to these components.” Nothing runs until you write that line. - Multiple inputs to one function are passed as a plain Python list, in the same order the function expects them as positional arguments, exactly like
convert_case(text, mode)above.
Verifying it from the command line with the Gradio Python client
You do not have to click through a browser to prove an app works. Gradio ships a client library, gradio_client, that talks to any running Gradio app over HTTP, which is exactly how you would test one in an automated script or a CI pipeline. Install it (it is usually already present as a dependency of gradio itself) and, with app.py still running in another terminal, run:
from gradio_client import Client
client = Client("http://127.0.0.1:7860/")
client.view_api()
The real output on this project looked like this:
Client.predict() Usage Info
---------------------------
Named API endpoints: 2
- predict(text, api_name="/analyze_text") -> (word_count, character_count, most_common_word)
- predict(text, mode, api_name="/convert_case") -> result
That is worth reading carefully: Gradio automatically named each API endpoint after the Python function attached to it (/analyze_text, /convert_case), not after the button that triggers it. This is exactly what you need when you later want to call your app’s logic from another program without going through the browser at all. Confirm it works with a real call:
client.predict("The quick brown fox jumps over the lazy dog", api_name="/analyze_text")
# (9, 43, 'the (2x)')
Step 5: Remembering Things With Session State
Right now, every click is independent: the app has no memory of what you analyzed a moment ago. Gradio’s own documentation on managing state describes three separate mechanisms, and the distinction matters because mixing them up leaks data between users:
- Global state: any plain variable created outside of a function. It is shared by every single visitor to your app at once, which is rarely what you want for user-specific data.
- Session state (
gr.State): “data persists across multiple submits within a page session,” but, in Gradio’s own words, “session data is not shared between different users of your model, and does not persist if a user refreshes the page.” This is what you want for a per-visitor history. - Browser state: persisted in the browser’s own local storage, surviving page refreshes and closes. Out of scope for this tutorial, but worth knowing exists.
Add a running history to the Analyzer tab using gr.State. The official recipe is three steps: create a gr.State object with a starting value, list it as both an input and an output of your event listener, and add it as a parameter and a return value of your function:
def analyze_text(text, history):
if not text.strip():
raise gr.Error("Please enter some text before analyzing.")
words = text.split()
word_count = len(words)
char_count = len(text)
counts = Counter(w.strip(".,!?;:").lower() for w in words)
most_common, freq = counts.most_common(1)[0]
preview = text if len(text) <= 30 else text[:27] + "..."
history = history + [[preview, word_count, char_count]]
return word_count, char_count, f"{most_common} ({freq}x)", history, history
def clear_history():
return [], []
Declare the state object once, at the top of your Blocks layout (its position does not affect what it renders, since gr.State has no visible component of its own), then wire it into the Analyzer tab’s table and click handler:
history_state = gr.State([])
with gr.Tab("Analyzer"):
with gr.Row():
with gr.Column():
analyze_input = gr.Textbox(label="Text to analyze", lines=4)
analyze_btn = gr.Button("Analyze", variant="primary")
clear_btn = gr.Button("Clear history")
with gr.Column():
word_count_out = gr.Number(label="Word count")
char_count_out = gr.Number(label="Character count")
common_word_out = gr.Textbox(label="Most common word")
history_table = gr.Dataframe(
headers=["Text", "Words", "Characters"],
label="History (this session)",
)
analyze_btn.click(
fn=analyze_text,
inputs=[analyze_input, history_state],
outputs=[word_count_out, char_count_out, common_word_out, history_state, history_table],
)
clear_btn.click(fn=clear_history, inputs=None, outputs=[history_state, history_table])
Notice the function’s return statement lists history twice at the end: once to update history_state itself, and once more so the identical list also updates the visible history_table. Calling the function twice in a row against a running app proves the memory works:
client.predict("The quick brown fox jumps over the lazy dog", api_name="/analyze_text")
# (9, 43, 'the (2x)', {'headers': [...], 'data': [['The quick brown fox jumps o...', 9, 43]], ...})
client.predict("Foxes are quick and foxes are clever", api_name="/analyze_text")
# (7, 36, 'foxes (2x)', {'headers': [...], 'data': [[...9, 43], ['Foxes are quick and foxes a...', 7, 36]], ...})
The second call’s history table has two rows, not one. And crucially, opening a second, independent connection to the exact same running app (a stand-in for a second visitor) starts with a completely empty history, proving state really is scoped per session and not shared globally:
session_a = Client("http://127.0.0.1:7860/")
session_a.predict("first message", api_name="/analyze_text")
session_a.predict("second message", api_name="/analyze_text")
# session A now has 2 rows in its history
session_b = Client("http://127.0.0.1:7860/")
session_b.predict("only message for session B", api_name="/analyze_text")
# session B has exactly 1 row, session A's history never appears here
Gotcha: gr.State values must be deep-copyable
Gradio’s documentation is explicit that “gr.State objects must be deepcopy-able,” because Gradio isolates each session’s copy of the state by deep-copying it. Plain data, lists, dictionaries, strings, and numbers, is always safe. If you ever try to store something that cannot be deep-copied, like an open network connection, a database cursor, or a thread lock, you will hit an error the moment a second session starts. Keep session state limited to plain data and pass live resources (like a database connection) in through the function’s own setup code instead.
Step 6: Rejecting Bad Input on Purpose
Right now, submitting an empty string crashes into a raw Python traceback in your server’s terminal, which is not something you want an actual user to ever see. Gradio has a purpose-built exception for this: gr.Error. Raising it, instead of letting a normal exception propagate, tells Gradio to show the user a clean, dismissible error message with your exact text, instead of a stack trace or a frozen page. You already saw it above, inside analyze_text:
if not text.strip():
raise gr.Error("Please enter some text before analyzing.")
Add the same guard to convert_case:
def convert_case(text, mode):
if not text.strip():
raise gr.Error("Please enter some text to convert.")
if mode == "UPPERCASE":
return text.upper()
if mode == "lowercase":
return text.lower()
return text.title()
Calling the analyzer with an empty string through the client confirms exactly what a real user’s browser would show, surfaced as a real exception on the client side too:
client.predict("", api_name="/analyze_text")
gradio_client.exceptions.AppError: Please enter some text before analyzing.
On the server side, the terminal running app.py still logs a traceback ending in gradio.exceptions.Error: 'Please enter some text before analyzing.' That traceback is expected and harmless: it is Gradio’s own internal logging of the error it just caught and handled cleanly for the user, not a sign that anything crashed. The app keeps running and accepting new requests immediately afterward.
Step 7: Verify Everything With an Automated Test Suite
Clicking through the browser after every change does not scale. Because the actual logic lives in plain Python functions (analyze_text, convert_case, clear_history), you can test that logic directly with pytest, without starting a web server at all. Save your final app as app.py with a small refactor so the functions are easy to import, then create test_app.py:
import gradio as gr
import pytest
from app import analyze_text, clear_history, convert_case
def test_analyze_text_counts_words_and_chars():
word_count, char_count, common, history, history_echo = analyze_text(
"The quick brown fox jumps over the lazy dog", []
)
assert word_count == 9
assert char_count == 43
assert common == "the (2x)"
def test_analyze_text_appends_to_history():
history = []
_, _, _, history, _ = analyze_text("one two", history)
_, _, _, history, _ = analyze_text("three four five", history)
assert len(history) == 2
assert history[0][1] == 2
assert history[1][1] == 3
def test_analyze_text_does_not_mutate_input_history():
original = []
analyze_text("some words here", original)
assert original == []
def test_analyze_text_rejects_empty_input():
with pytest.raises(gr.Error):
analyze_text(" ", [])
def test_clear_history_resets_state():
assert clear_history() == ([], [])
def test_convert_case_uppercase():
assert convert_case("hello world", "UPPERCASE") == "HELLO WORLD"
def test_convert_case_lowercase():
assert convert_case("HELLO WORLD", "lowercase") == "hello world"
def test_convert_case_title():
assert convert_case("hello world", "Title Case") == "Hello World"
def test_convert_case_rejects_empty_input():
with pytest.raises(gr.Error):
convert_case("", "UPPERCASE")
Notice that test_analyze_text_does_not_mutate_input_history checks something easy to get wrong silently: the function builds history + [[...]] (a brand new list) rather than calling history.append(...) (which would modify the caller’s original list in place). Returning a new object instead of mutating the one you were given is a small habit that avoids a whole category of “why did this other variable also change” bugs once state gets passed around multiple functions.
Run the suite. No browser, no running server, required:
pytest test_app.py -v
The real output from this exact test file:
test_app.py::test_analyze_text_counts_words_and_chars PASSED [ 11%]
test_app.py::test_analyze_text_appends_to_history PASSED [ 22%]
test_app.py::test_analyze_text_does_not_mutate_input_history PASSED [ 33%]
test_app.py::test_analyze_text_rejects_empty_input PASSED [ 44%]
test_app.py::test_clear_history_resets_state PASSED [ 55%]
test_app.py::test_convert_case_uppercase PASSED [ 66%]
test_app.py::test_convert_case_lowercase PASSED [ 77%]
test_app.py::test_convert_case_title PASSED [ 88%]
test_app.py::test_convert_case_rejects_empty_input PASSED [100%]
9 passed in 2.12s
Every one of those assertions is a real, checkable claim about your app’s behavior. If you refactor analyze_text next month and accidentally change how word counting works, this suite fails immediately, long before a user notices anything odd in the browser.
Common Mistakes and Gotchas Recap
- “Cannot find empty port” on launch: a previous run is still listening on that port. Stop it (see Step 2) or pick a different port with
server_port=. - Extra output values get silently stringified, not rejected: if a Gradio output looks like a raw Python tuple or object printed as text, recount your function’s return values against your
outputslist first (Step 3). - gr.State needs plain, deep-copyable data: lists, dicts, strings, and numbers are safe; live resources like open connections are not (Step 5).
- An uncaught exception shows a scary generic error to users: validate input yourself and raise
gr.Error("your message")instead of letting a normal exception reach the user (Step 6). - Forgetting
if __name__ == "__main__":arounddemo.launch(): without it, importing your app module for testing (astest_app.pydoes) will also try to start a live web server as a side effect of the import.
Next Steps: Sharing Your App With Other People
Everything above ran on 127.0.0.1, reachable only from your own machine. Gradio documents two ways to change that, and it is worth understanding both before you reach for either, since neither was exercised as a live, executed step in this tutorial (the first opens a public network tunnel; the second needs an account on a third-party service), but both are accurately described here directly from Gradio’s own documentation so you know exactly what you are opting into.
A temporary public link with share=True
demo.launch(share=True)
According to Gradio’s own sharing guide, this “generates a public, shareable link that you can send to anybody,” typically shaped like https://07ff8706ab.gradio.live, proxied through Gradio’s own share servers back to your machine. Two details from that same documentation matter before you use it on anything real: “share links expire after 1 week,” and “share links are publicly accessible, meaning that anyone can use your model for prediction.” Treat a shared link exactly like a public API endpoint: never enable it on an app whose functions touch private data, real credentials, or paid resources without adding your own authentication in front of it.
A permanent home on Hugging Face Spaces
For a link that does not expire, Gradio’s documentation describes running gradio deploy from your project’s terminal after creating a free Hugging Face account. That command “will gather some basic metadata, upload all the files in the current directory… and then launch your app on Spaces.” Re-running the same command later pushes updates to the same Space.
Confirm It All Works, End to End
Before calling this done, walk through the whole thing once from a clean state, the same sequence you would hand to a teammate:
- Activate your virtual environment and run
python app.py. Confirm you see aRunning on local URLline with no traceback above it. - In a browser, open that URL, submit a sentence on the Analyzer tab, and confirm the word count, character count, and most common word all look right, and that a row appears in the history table.
- Submit a second sentence and confirm the history table now has two rows, not one; then click Clear history and confirm it empties.
- Switch to the Case Converter tab, try all three modes, and confirm each produces the expected casing.
- Submit an empty string on either tab and confirm you see a clean error message, not a frozen page or a raw traceback.
- Stop the server, then run
pytest test_app.py -vand confirm all nine tests pass.
If every one of those checks holds, you have a working, tested, two-tool Gradio application built entirely in Python, and, more importantly, you understand why each piece behaves the way it does rather than having copied it from an example.
Where to go from here
- Add a third tool to the same app as a new
gr.Tab, for example a word-frequency table usinggr.BarPlot. - If you want to orchestrate several AI processing steps as one pipeline rather than independent tools in tabs, sxz.io’s tutorial on Gradio’s gr.Workflow picks up exactly where this one leaves off.
- Wire a real local model into one of your tabs using Ollama, so the Analyzer tab calls a language model instead of the
Counter-based logic used here. - Read Gradio’s own guide on managing state for the browser-storage option this tutorial did not cover.








No Comment! Be the first one.