
This project creates a small database query viewer with Tkinter. The user enters a SQL SELECT query, Python executes the query through SQLAlchemy, and the returned columns and rows are displayed dynamically in a Treeview.
The same GUI can work with MySQL or SQLite because the Treeview code only needs two things from the query result:
column headings
rows of data
Treeview and Scrollbar are available through ttk.
import tkinter as tk
from tkinter import ttk, messagebox
For database access:
from sqlalchemy import create_engine, text
from sqlalchemy.exc import SQLAlchemyError
The responsive version also uses:
import queue
import threading
engine=create_engine('mysql+mysqldb://userid:password@localhost/db_name')
See Python MySQL connection for connection details.
engine=create_engine('sqlite:///my_db.db')
See SQLite database connection.
Only one of these Engine definitions is required in the program.
SELECT improves the interface, but it should not be treated as database security. The database user's privileges are the stronger protection.A multi-line Text widget is appropriate because SQL queries can span several lines.
query_box=tk.Text(root,height=5,width=80,font=('Times',13))
query_box.grid(row=0,column=0,sticky='ew',padx=10,pady=10)
Read the query without the Text widget's trailing newline:
query=query_box.get('1.0','end-1c').strip()
This tutorial intentionally accepts only a single straightforward SELECT statement.
def validate_query(query):
cleaned=query.strip()
if not cleaned:
return False,'Enter a SELECT query.'
cleaned=cleaned.rstrip(';').strip()
if ';' in cleaned:
return False,'Enter one SQL statement only.'
if not cleaned.lower().startswith('select '):
return False,'Only SELECT queries are allowed in this viewer.'
return True,cleaned
This deliberately excludes statements such as:
UPDATE
DELETE
INSERT
DROP
ALTER
The goal of this application is viewing reports, not modifying the database.
Use a short-lived connection:
with engine.connect() as conn:
result=conn.execute(text(query))
headers=list(result.keys())
rows=[tuple(row) for row in result.fetchmany(MAX_ROWS+1)]
result.keys() gives the returned column headings.
For example:
SELECT id,name,mark FROM student
can return:
['id', 'name', 'mark']
The Treeview can then create exactly three visible columns.
A database query can take time. If it runs directly inside the button callback, Tkinter cannot process normal window events while the query is waiting.
Run the database work in a worker thread:
threading.Thread(target=query_worker,args=(query,),daemon=True).start()
The worker puts plain result data into a queue:
result_queue.put(('ok',headers,rows,truncated))
Tkinter's main thread checks the queue with after() and updates the Treeview.
The returned database headings should be used as visible labels. Internally, use safe unique identifiers:
column_ids=[f'col{i}' for i in range(len(headers))]
tree['columns']=column_ids
Configure the visible headings:
for i,(column_id,heading) in enumerate(zip(column_ids,headers),start=1):
heading_text=str(heading).strip() if heading is not None else ''
if not heading_text:
heading_text=f'Column {i}'
tree.heading(column_id,text=heading_text)
tree.column(column_id,width=120,minwidth=70,anchor='w')
An arbitrary query can return:
SELECT class,COUNT(*) FROM student GROUP BY class
or:
SELECT name,name FROM student
The first returned value is not guaranteed to be a unique primary key.
For a general SQL report viewer, let Treeview create its own item IDs:
tree.insert('',tk.END,values=row)
This differs from our dedicated MySQL student-record Treeview, where we know that student.id is a unique primary key.
A dynamic query may return many rows or many columns, so both scrollbars are useful.
ys=ttk.Scrollbar(result_frame,orient='vertical',command=tree.yview)
xs=ttk.Scrollbar(result_frame,orient='horizontal',command=tree.xview)
tree.configure(yscrollcommand=ys.set,xscrollcommand=xs.set)
A named style prevents this project from changing unrelated Treeview widgets.
style=ttk.Style(root)
if 'clam' in style.theme_names():
style.theme_use('clam')
style.configure('Query.Treeview',background='black',fieldbackground='black',foreground='white',rowheight=26)
style.configure('Query.Treeview.Heading',background='PowderBlue',foreground='black')
See Treeview style for more options.
An unrestricted SELECT query can return thousands or millions of rows. Displaying all of them in one desktop Treeview is usually not useful.
The complete example uses:
MAX_ROWS=500
and retrieves at most:
MAX_ROWS+1
rows for the GUI.
If the extra row exists, the interface reports:
Showing first 500 rows. Add LIMIT to the SQL query for a smaller result.
WHERE, LIMIT and indexed conditions in the SQL itself.import queue
import threading
import tkinter as tk
from tkinter import ttk, messagebox
from sqlalchemy import create_engine, text
from sqlalchemy.exc import SQLAlchemyError
engine=create_engine('mysql+mysqldb://userid:password@localhost/db_name')
# SQLite alternative:
# engine=create_engine('sqlite:///my_db.db')
MAX_ROWS=500
result_queue=queue.Queue()
root=tk.Tk()
root.geometry('900x620')
root.title('SQL SELECT Query Viewer - plus2net')
root.rowconfigure(1,weight=1)
root.columnconfigure(0,weight=1)
query_frame=ttk.Frame(root)
query_frame.grid(row=0,column=0,sticky='ew',padx=10,pady=10)
query_frame.columnconfigure(0,weight=1)
query_box=tk.Text(query_frame,height=5,font=('Times',13),wrap='none')
query_box.grid(row=0,column=0,sticky='ew')
query_box.insert('1.0','SELECT id,name,class,mark,gender FROM student ORDER BY id LIMIT 20')
button_frame=ttk.Frame(query_frame)
button_frame.grid(row=0,column=1,sticky='ns',padx=(10,0))
run_btn=tk.Button(button_frame,text='Run SELECT',command=lambda:run_query())
run_btn.pack(fill=tk.X,pady=(0,6))
clear_btn=tk.Button(button_frame,text='Clear',command=lambda:query_box.delete('1.0',tk.END))
clear_btn.pack(fill=tk.X)
style=ttk.Style(root)
if 'clam' in style.theme_names():
style.theme_use('clam')
style.configure('Query.Treeview',background='black',fieldbackground='black',foreground='white',rowheight=26)
style.configure('Query.Treeview.Heading',background='PowderBlue',foreground='black')
result_frame=ttk.Frame(root)
result_frame.grid(row=1,column=0,sticky='nsew',padx=10)
result_frame.rowconfigure(0,weight=1)
result_frame.columnconfigure(0,weight=1)
tree=ttk.Treeview(result_frame,show='headings',selectmode='browse',style='Query.Treeview')
tree.grid(row=0,column=0,sticky='nsew')
ys=ttk.Scrollbar(result_frame,orient='vertical',command=tree.yview)
ys.grid(row=0,column=1,sticky='ns')
xs=ttk.Scrollbar(result_frame,orient='horizontal',command=tree.xview)
xs.grid(row=1,column=0,sticky='ew')
tree.configure(yscrollcommand=ys.set,xscrollcommand=xs.set)
status_var=tk.StringVar(value='Ready - SELECT queries only')
status_label=tk.Label(root,textvariable=status_var,anchor='w')
status_label.grid(row=2,column=0,sticky='ew',padx=10,pady=8)
def validate_query(query):
cleaned=query.strip()
if not cleaned:
return False,'Enter a SELECT query.'
cleaned=cleaned.rstrip(';').strip()
if ';' in cleaned:
return False,'Enter one SQL statement only.'
if not cleaned.lower().startswith('select '):
return False,'Only SELECT queries are allowed.'
return True,cleaned
def run_query(event=None):
query=query_box.get('1.0','end-1c')
valid,result=validate_query(query)
if not valid:
messagebox.showwarning('Query',result)
return
run_btn.config(state=tk.DISABLED)
clear_btn.config(state=tk.DISABLED)
query_box.config(state=tk.DISABLED)
status_var.set('Running query...')
threading.Thread(target=query_worker,args=(result,),daemon=True).start()
def query_worker(query):
try:
with engine.connect() as conn:
result=conn.execute(text(query))
headers=list(result.keys())
rows=[tuple(row) for row in result.fetchmany(MAX_ROWS+1)]
truncated=len(rows)>MAX_ROWS
if truncated:
rows=rows[:MAX_ROWS]
result_queue.put(('ok',headers,rows,truncated))
except SQLAlchemyError as e:
detail=str(e.orig) if getattr(e,'orig',None) else str(e)
result_queue.put(('error',detail,None,False))
def show_results(headers,rows):
for item in tree.get_children():
tree.delete(item)
column_ids=[f'col{i}' for i in range(len(headers))]
tree['columns']=column_ids
tree['show']='headings'
for i,(column_id,heading) in enumerate(zip(column_ids,headers),start=1):
heading_text=str(heading).strip() if heading is not None else ''
if not heading_text:
heading_text=f'Column {i}'
width=max(90,min(220,len(heading_text)*10+30))
tree.heading(column_id,text=heading_text)
tree.column(column_id,width=width,minwidth=70,anchor='w')
for row in rows:
tree.insert('',tk.END,values=row)
def check_results():
try:
status,data,rows,truncated=result_queue.get_nowait()
except queue.Empty:
pass
else:
run_btn.config(state=tk.NORMAL)
clear_btn.config(state=tk.NORMAL)
query_box.config(state=tk.NORMAL)
if status=='ok':
headers=data
show_results(headers,rows)
if truncated:
status_var.set(f'Showing first {MAX_ROWS} rows. Add LIMIT to the query for a smaller result.')
else:
status_var.set(f'Query complete: {len(rows)} row(s), {len(headers)} column(s).')
else:
status_var.set('Query failed.')
messagebox.showerror('SQL Error',data)
root.after(100,check_results)
def on_close():
engine.dispose()
root.destroy()
query_box.bind('<Control-Return>',run_query)
root.protocol('WM_DELETE_WINDOW',on_close)
root.after(100,check_results)
root.mainloop()
SELECT id,name,class,mark,gender
FROM student
ORDER BY id
LIMIT 20
SELECT id,name,class,mark
FROM student
WHERE mark >= 75
ORDER BY mark DESC
SELECT class,COUNT(*) AS total_students
FROM student
GROUP BY class
ORDER BY class
SELECT name,mark
FROM student
ORDER BY mark DESC
LIMIT 10
The Treeview automatically changes from five columns to two because the layout is generated from the query result.
More SELECT queries can be tested using the existing Plus2net SQL exercises:
SQL Date Queries SQL ExercisesA report viewer should normally use SELECT-only credentials and a SELECT-only interface.
This:
query.lower().startswith('select ')
is an interface restriction, not a replacement for database permissions.
Use:
with engine.connect() as conn:
result=conn.execute(text(query))
Let the Engine manage connections and open them only when required.
Create the widget once. Change its columns and replace its rows.
A reporting query can return aggregates, names, dates or duplicated values. Let Treeview generate item IDs.
Queries can return blank or duplicate column labels. Use internal IDs such as col0 and col1.
The GUI can become unresponsive. The complete example performs database work in a worker thread.
Use WHERE and LIMIT in the SQL query rather than trying to display an unnecessarily large report.
There is no need to repeatedly destroy or hide the result widgets. Reuse one Treeview and its existing scrollbars.
Yes. Read the returned column names, rebuild the Treeview column configuration and insert the new result rows.
The application is intended as a report and data-viewing tool. Restricting it to SELECT avoids exposing modification operations through the query box.
No. Use database credentials with appropriate read-only permissions. Application-level validation should not be the only security control.
An arbitrary SELECT query does not guarantee that its first returned value is unique. Let Treeview generate item IDs for general report results.
Yes. Change the SQLAlchemy Engine URL to the SQLite database. The dynamic Treeview code remains the same.
A slow database operation running in Tkinter's main thread can freeze the interface. A worker thread allows the event loop to remain responsive.
Add suitable WHERE and LIMIT clauses to the SQL query and optionally enforce a maximum number of displayed rows in the application.
engine.connect() and text().result.keys() provides query-result column headings.col0 and col1.Author & Instructor at plus2net
I write and maintain practical tutorials on Python, PHP, SQL, JavaScript, HTML, jQuery, and web development at plus2net. The tutorials focus on clear explanations, working examples, and code that readers can test and adapt while learning.