Connect to SQL Server with pyodbc, execute a newest-first SELECT, convert the cursor results to a pandas DataFrame, create a unique .xlsx path with pathlib, and export through pd.ExcelWriter(engine="openpyxl"). Use openpyxl to freeze the header, add filters, style cells, format dates/numbers and size columns.
- SQL Server supplies the latest 100 furnace events.
- pandas handles tabular data; openpyxl controls workbook presentation.
- Timestamped filenames prevent normal report runs from overwriting each other.
What This SQL Server-to-Excel Python Tutorial Covers
This guide targets developers and automation engineers searching for a reliable way to export SQL Server query results to Excel with Python. It combines pyodbc for SQL access, pandas for tabular data, and openpyxl for professional XLSX formatting.
Related Search Topics
export SQL query results to Excel Python · SQL Server to pandas DataFrame to Excel · format Excel report with openpyxl · automate SQL Server Excel reports
1. Export SQL Server Data to Excel: Reporting Flow
This program converts operational SQL data into a user-friendly Excel report. It validates the source table, reads a controlled batch, preserves SQL column names, writes the data, applies presentation formatting and closes the database connection regardless of success or failure.
SQL Server → pandas → openpyxl → Excel
Convert operational SQL data into a formatted Excel report by reading a controlled record set, building a DataFrame, writing XLSX and applying workbook formatting.
Connect SQL Server
Open SQF_DB through pyodbc and verify the source table.
Query Records
Read the latest controlled batch from dbo.tblEvent.
Build DataFrame
Preserve SQL column names while moving rows into pandas.
Create Report Path
Generate a unique XLSX filename in the report folder.
Write Excel
Export the DataFrame through the openpyxl Excel engine.
Format & Verify
Freeze header, filter, format numbers/dates and save the workbook.
Code-rendered diagram: the reporting layers are separated so learners can understand where SQL retrieval ends and Excel presentation begins.
Lab Manual Index: 8 Guided Exercises
Every section of this tutorial carries its own lab manual — objective, procedure, a line-by-line explanation, copy-paste practice code, the exact console output to expect, a checkpoint and a troubleshooting table. Work through them in order; each lab builds on the checkpoint before it. Total bench time is about 63 minutes.
2. Requirements and Configuration
python -m pip install --upgrade pip
python -m pip install pyodbc pandas openpyxlfrom pathlib import Path
SERVER = r"SOFTWELL\WINCC"
DATABASE = "SQF_DB"
TABLE = "dbo.tblEvent"
DRIVER = "ODBC Driver 17 for SQL Server"
# Export the latest 100 records
TOP_RECORDS = 100
# Excel report folder
OUTPUT_FOLDER = Path(r"D:\SQF_Reports")The raw server/path strings preserve Windows backslashes. The program needs SQL SELECT access plus write permission for D:\SQF_Reports. Keep connection and output settings in trusted configuration for production deployments.
3. Connect to SQL Server and Verify the Table
import pyodbc
connection_string = (
f"DRIVER={{{DRIVER}}};"
f"SERVER={SERVER};"
f"DATABASE={DATABASE};"
"Trusted_Connection=yes;"
"TrustServerCertificate=yes;"
)
connection = pyodbc.connect(connection_string, timeout=15)
cursor = connection.cursor()cursor.execute("""
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'dbo'
AND TABLE_NAME = 'tblEvent'
""")
table_available = cursor.fetchone()[0]
if table_available == 0:
raise RuntimeError("Table dbo.tblEvent is not available.")The existence check fails early with a clear message instead of letting the later query produce a less focused error. In a production report, also validate required columns and compatible types or manage the schema through versioned migrations.
Lab Manual 3 — Connect to SQL Server and Verify the Table
8 minObjective
Open the exact SQL Server instance and database the report will read, and confirm the source table exists before a single row or workbook is produced.
Procedure
- Open ODBC Data Sources (64-bit), go to the Drivers tab and copy the driver name exactly as Windows shows it.
- Edit
SERVER,DATABASE,TABLEandDRIVERin the settings block. - Run the connection and table check together, before any SELECT is written.
- Confirm the printed server and database names match what you intended.
What each line does
| Code | Meaning in this lab |
|---|---|
| r"SOFTWELL\WINCC" | Raw string. Without the leading r, Python reads \W as an escape sequence and the named instance breaks. |
| f"DRIVER={{{DRIVER}}};" | Three braces produce one literal {, the driver name, and one literal } — ODBC requires the braces. |
| Trusted_Connection=yes | Uses the Windows account running the script, so no password sits in the report file. |
| TrustServerCertificate=yes | Skips certificate validation. Fine inside a closed training lab, not on a plant server. |
| timeout=15 | Login timeout in seconds, so a scheduled report fails fast instead of hanging overnight. |
| INFORMATION_SCHEMA.TABLES | ANSI catalog view. A count of zero means the table is missing or invisible to your login. |
| raise RuntimeError(...) | Stops with a readable message instead of letting the failure surface later as a confusing SELECT error. |
Copy-paste practice code
import pyodbc
print("Installed ODBC drivers:")
for driver in pyodbc.drivers():
print(" -", driver)
connection = pyodbc.connect(connection_string, timeout=15)
cursor = connection.cursor()
cursor.execute("SELECT @@SERVERNAME, DB_NAME(), SYSTEM_USER")
server_name, database_name, login_name = cursor.fetchone()
print("Server :", server_name)
print("Database:", database_name)
print("Login :", login_name)
cursor.execute(f"SELECT COUNT(*) FROM {TABLE}")
print("Rows available:", cursor.fetchone()[0])
connection.close()TABLE is a fixed constant in your own script, never user input, so it is safe inside the f-string. Data values always stay in parameters.
Expected result
Installed ODBC drivers:
- SQL Server
- ODBC Driver 17 for SQL Server
Server : SOFTWELL\WINCC
Database: SQF_DB
Login : SOFTWELL\Trainer
Rows available: 100Checkpoint
If it fails
| Symptom | What to check |
|---|---|
| IM002 — data source name not found | Driver string misspelled, or 32-bit driver installed while 64-bit Python is running. Compare with the printed list. |
| 08001 — SQL Server does not exist | Wrong instance name, SQL Server Browser stopped, or TCP/IP disabled in SQL Server Configuration Manager. |
| Table dbo.tblEvent is not available | Connected to the wrong database, wrong schema, or the login has no rights on the object. |
| Rows available: 0 | The table exists but is empty. Run the insert practical (Code 02) before generating a report. |
4. Read the Latest SQL Records
select_query = f"""
SELECT TOP ({TOP_RECORDS})
DT, TM, SQF_No, ChargeNo,
Event_From, Event_To,
Temp_Set, Temp_Act,
Cp_Set, Cp_Act,
Oil_Set, Oil_Act,
Jacket_Set, Jacket_Act,
Fan_Status
FROM dbo.tblEvent
ORDER BY DT DESC, TM DESC
"""
cursor.execute(select_query)The query returns newest records first. TOP_RECORDS is an integer constant controlled by the program, so its interpolation is not accepting user SQL. If the limit becomes external input, validate it as a bounded integer or use a supported bound parameter.
Add a unique descending tie-breaker such as Event_ID DESC when two records can share the same DT and TM.
Lab Manual 4 — Read the Latest SQL Records for the Report
7 minObjective
Retrieve exactly the columns the report needs, in the order the Excel sheet will show them, sorted so the newest event is row one.
Procedure
- Set
TOP_RECORDS = 5for the first run so a mistake is cheap. - Run the query and print the raw cursor rows before pandas or Excel is involved.
- Confirm the column order matches the order you want in the workbook.
- Raise
TOP_RECORDSto 100 once the small batch looks right.
What each line does
| Code | Meaning in this lab |
|---|---|
| f"""...TOP ({TOP_RECORDS})...""" | The limit is an integer constant you control, inserted with an f-string. Never insert user-supplied text this way. |
| DT, TM, SQF_No, ... | Columns are named explicitly, so the Excel column order is fixed and the report does not change when the table gains a column. |
| ORDER BY DT DESC, TM DESC | Both date and time descend, so the newest event lands in row 2 of the sheet, directly under the header. |
| cursor.execute(select_query) | Runs the statement. Nothing is read into memory until fetchall() is called. |
| Column positions | This order is what makes columns G to N the numeric ones in Section 8. Change the SELECT and you must change those letters. |
Copy-paste practice code
TOP_RECORDS = 5
cursor.execute(select_query)
print("Column order for the Excel sheet:")
for position, column in enumerate(cursor.description, start=1):
letter = chr(64 + position)
print(f" {letter}: {column[0]}")
print("\nRows:")
for index, row in enumerate(cursor.fetchall(), start=1):
print(index, row.DT, row.TM, row.SQF_No, row.Event_To, row.Temp_Act)The letter column in this output is the one to keep. It tells you which spreadsheet columns the number formatting in Section 8 will apply to.
Expected result
Column order for the Excel sheet:
A: DT
B: TM
C: SQF_No
D: ChargeNo
E: Event_From
F: Event_To
G: Temp_Set
H: Temp_Act
I: Cp_Set
J: Cp_Act
K: Oil_Set
L: Oil_Act
M: Jacket_Set
N: Jacket_Act
O: Fan_Status
Rows:
1 2026-09-19 12:43:00 3 Charge Discharged 96.21
2 2026-09-19 12:42:00 3 Cooling 148.77Checkpoint
If it fails
| Symptom | What to check |
|---|---|
| Invalid column name | The table schema differs from the example. Compare the SELECT list with INFORMATION_SCHEMA.COLUMNS. |
| Rows return in the wrong order | DT or TM is stored as text, so it sorts alphabetically. Use proper date and time types. |
| Identical timestamps swap between runs | Ties have no defined order. Add a unique descending key such as Event_ID DESC. |
| Numeric formatting lands on the wrong columns | The SELECT order changed but the numeric_columns letters in Section 8 did not. |
5. Convert Cursor Results into a DataFrame
import pandas as pd
column_names = [column[0] for column in cursor.description]
rows = cursor.fetchall()
if not rows:
raise RuntimeError("No records are available in dbo.tblEvent.")
dataframe = pd.DataFrame.from_records(rows, columns=column_names)cursor.description supplies the selected SQL column names. The row list and names become one labeled pandas DataFrame. The empty check prevents creating a misleading blank report.
fetchall() is appropriate for 100 rows. Large exports should use chunked queries/writes or a streaming strategy so the full result does not occupy memory twice as cursor rows and DataFrame data.
Lab Manual 5 — Convert Cursor Results into a DataFrame
6 minObjective
Build a labelled pandas DataFrame from the cursor result and stop the program cleanly when the query returned nothing, rather than writing an empty workbook.
Procedure
- Build the DataFrame from the small batch of Lab 4.
- Print
shapeanddtypes. - Note which numeric columns arrived as
object— those will not right-align in Excel as numbers. - Test the empty guard by pointing the query at a filter that returns no rows.
What each line does
| Code | Meaning in this lab |
|---|---|
| cursor.description | Metadata for the returned columns. Element zero of each tuple is the column name. |
| cursor.fetchall() | Reads the remaining rows into memory. Safe here because TOP_RECORDS limits the query. |
| if not rows: | An empty result stops the run. Without this, the script would create a workbook with a header and no data. |
| pd.DataFrame.from_records(...) | Combines the row tuples with the column names into a labelled table. |
| columns=column_names | Without this, pandas numbers the columns 0 to 14 and the Excel header row would be numbers instead of names. |
Copy-paste practice code
print("Shape (rows, columns):", dataframe.shape)
print("\nData types:")
print(dataframe.dtypes.to_string())
print("\nMissing values per column:")
print(dataframe.isna().sum().to_string())
print("\nFirst 3 rows as they will appear in Excel:")
print(dataframe.head(3).to_string(index=False))Any numeric column showing dtype object will be written to Excel as text, so the 0.00 number format in Section 8 will have no visible effect on it.
Expected result
Shape (rows, columns): (100, 15)
Data types:
DT object
TM object
SQF_No int64
Temp_Set float64
Temp_Act float64
Fan_Status object
Missing values per column:
DT 0
Temp_Act 0
Fan_Status 0Checkpoint
TOP_RECORDS, all fifteen columns carry their SQL names, and the eight process columns are numeric rather than object.If it fails
| Symptom | What to check |
|---|---|
| Excel header row shows 0, 1, 2 ... | The columns= argument was dropped from from_records(). |
| RuntimeError: No records are available | The table is empty for this query. This is the guard working correctly, not a bug. |
| Numbers appear left-aligned in Excel | That column is dtype object, so Excel stores it as text. Apply pd.to_numeric() before writing. |
| MemoryError on a large table | fetchall() loads everything. Keep the TOP limit or read in chunks. |
6. Create a Unique Excel Report Path
from datetime import datetime
OUTPUT_FOLDER.mkdir(parents=True, exist_ok=True)
# Date, time, seconds and microseconds prevent overwriting
timestamp = datetime.now().strftime("%d-%m-%Y_%H-%M-%S-%f")
excel_file = OUTPUT_FOLDER / f"SQF_Event_Report_{timestamp}.xlsx"Path.mkdir(..., exist_ok=True) creates the output folder and any missing parents without failing when it already exists. Date, time and microseconds make filename collisions unlikely during normal sequential runs.
For multi-process or distributed jobs, use a UUID or atomic file-creation pattern. Consider starting filenames with YYYY-MM-DD so ordinary alphabetical sorting also follows date order.
Lab Manual 6 — Create a Unique Excel Report Path
6 minObjective
Produce a report path that is guaranteed to be new on every run, and create the output folder if it does not yet exist.
Procedure
- Set
OUTPUT_FOLDERto a folder you own — not a network share for the first run. - Run the path builder twice in a row and compare the two filenames.
- Open the folder in Explorer and confirm it was created automatically.
What each line does
| Code | Meaning in this lab |
|---|---|
| Path(r"D:\SQF_Reports") | pathlib.Path handles Windows separators safely. The raw string keeps the backslashes literal. |
| mkdir(parents=True, exist_ok=True) | Creates the folder and any missing parents, and does nothing if it already exists — so a rerun is safe. |
| strftime("%d-%m-%Y_%H-%M-%S-%f") | Day, month, year, hour, minute, second and microseconds. The microseconds are what make two runs in the same second distinct. |
| OUTPUT_FOLDER / f"..." | The / operator joins paths. It is safer than string concatenation, which loses or doubles separators. |
| .xlsx | The extension must match the writer engine. openpyxl writes xlsx, not the legacy xls format. |
Copy-paste practice code
from datetime import datetime
from pathlib import Path
OUTPUT_FOLDER = Path(r"D:\SQF_Reports")
OUTPUT_FOLDER.mkdir(parents=True, exist_ok=True)
for attempt in range(1, 4):
timestamp = datetime.now().strftime("%d-%m-%Y_%H-%M-%S-%f")
excel_file = OUTPUT_FOLDER / f"SQF_Event_Report_{timestamp}.xlsx"
print(attempt, excel_file.name)
print("\nFolder exists:", OUTPUT_FOLDER.exists())
print("Folder is writable:", OUTPUT_FOLDER.is_dir())
print("Existing reports:", len(list(OUTPUT_FOLDER.glob("SQF_Event_Report_*.xlsx"))))Three filenames generated in the same second must still differ. If they do not, the microsecond field is missing from the format string.
Expected result
1 SQF_Event_Report_19-09-2026_12-44-07-481293.xlsx
2 SQF_Event_Report_19-09-2026_12-44-07-481664.xlsx
3 SQF_Event_Report_19-09-2026_12-44-07-482011.xlsx
Folder exists: True
Folder is writable: True
Existing reports: 0Checkpoint
If it fails
| Symptom | What to check |
|---|---|
| FileNotFoundError on the drive | The drive letter does not exist on this PC. Use a local folder such as C:\SoftwellLabs\reports. |
| PermissionError creating the folder | The Windows account cannot write there. Choose a user-owned folder, not a protected system path. |
| Two runs produce the same filename | The %f microsecond field is missing from strftime. |
| Reports pile up forever | Expected. Add a retention step that deletes reports older than an agreed number of days. |
7. Write a pandas DataFrame to Excel with openpyxl
with pd.ExcelWriter(
excel_file,
engine="openpyxl",
datetime_format="DD-MM-YYYY HH:MM:SS",
) as writer:
dataframe.to_excel(
writer,
sheet_name="Event Report",
index=False,
)
worksheet = writer.sheets["Event Report"]The context manager saves and closes the workbook automatically. index=False prevents pandas’ row index from becoming an unwanted Excel column. writer.sheets exposes the openpyxl worksheet for detailed formatting before the file is finalized.
Lab Manual 7 — Write the DataFrame to an Excel Workbook
7 minObjective
Write the DataFrame into a real xlsx workbook with a named sheet, and hold the worksheet object so Section 8 can format it before the file is saved.
Procedure
- Write the workbook with the
withblock exactly as shown. - Open the file in Excel and confirm the sheet name and header row.
- Delete the file, remove
index=False, run again and compare — then restore it.
What each line does
| Code | Meaning in this lab |
|---|---|
| with pd.ExcelWriter(...) as writer | The context manager saves and closes the workbook when the block ends. Remove it and the file stays empty or locked. |
| engine="openpyxl" | Selects the writer that produces xlsx and exposes the worksheet object for formatting. |
| datetime_format="DD-MM-YYYY HH:MM:SS" | Excel number format applied to datetime cells as they are written. |
| sheet_name="Event Report" | Names the tab. Without it the sheet is called Sheet1, which looks unfinished in a delivered report. |
| index=False | Suppresses the pandas index. Without this, column A becomes 0, 1, 2 and every later column letter shifts by one. |
| writer.sheets["Event Report"] | Retrieves the live openpyxl worksheet so freeze panes, fills and widths can be applied before saving. |
Copy-paste practice code
from openpyxl import load_workbook
with pd.ExcelWriter(
excel_file,
engine="openpyxl",
datetime_format="DD-MM-YYYY HH:MM:SS",
) as writer:
dataframe.to_excel(writer, sheet_name="Event Report", index=False)
# Re-open the saved file and prove what actually landed on disk
workbook = load_workbook(excel_file)
worksheet = workbook["Event Report"]
print("Sheet names :", workbook.sheetnames)
print("Dimensions :", worksheet.dimensions)
print("Header row :", [cell.value for cell in worksheet[1]])
print("Data rows :", worksheet.max_row - 1)
workbook.close()Re-opening the saved file is the only honest check. A script that prints “report created” without reading the file back has proved nothing.
Expected result
Sheet names : ['Event Report']
Dimensions : A1:O101
Header row : ['DT', 'TM', 'SQF_No', 'ChargeNo', 'Event_From', 'Event_To', 'Temp_Set', 'Temp_Act', 'Cp_Set', 'Cp_Act', 'Oil_Set', 'Oil_Act', 'Jacket_Set', 'Jacket_Act', 'Fan_Status']
Data rows : 100Checkpoint
DT and not an index, and the data row count matches the DataFrame length.If it fails
| Symptom | What to check |
|---|---|
| Column A contains 0, 1, 2 ... | index=False was omitted. Every formatting letter in Section 8 is now shifted by one column. |
| ModuleNotFoundError: openpyxl | Install it with python -m pip install openpyxl inside the active environment. |
| The file is created but empty | The with block was replaced with a bare call, so the workbook was never saved. |
| PermissionError while writing | The workbook is open in Excel. Close it and run again. |
8. Format the Excel Report with openpyxl
from openpyxl.styles import Alignment, Font, PatternFill
# Freeze the heading row and add filters to every column
worksheet.freeze_panes = "A2"
worksheet.auto_filter.ref = worksheet.dimensions
# Format the header row
header_fill = PatternFill(fill_type="solid", fgColor="F4B183")
for cell in worksheet[1]:
cell.font = Font(bold=True)
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center", vertical="center")
# Format the date column
for cell in worksheet["A"][1:]:
cell.number_format = "DD-MM-YYYY HH:MM:SS"
# Format the process-value columns
numeric_columns = [
"G", "H", # Temperature
"I", "J", # CP
"K", "L", # Oil
"M", "N", # Jacket
]
for column_letter in numeric_columns:
for cell in worksheet[column_letter][1:]:
cell.number_format = "0.00"
cell.alignment = Alignment(horizontal="center")| Formatting feature | Benefit |
|---|---|
Freeze A2 | Keeps headings visible while scrolling |
| Auto filter | Lets users filter furnace, charge, stage and fan status |
| Orange bold header | Creates a visible report hierarchy |
| Date number format | Shows consistent timestamps |
0.00 numeric format | Standardizes process-value display |
| Calculated widths capped at 35 | Improves readability without extremely wide columns |
for column_cells in worksheet.columns:
column_letter = column_cells[0].column_letter
maximum_length = max(
len(str(cell.value)) if cell.value is not None else 0
for cell in column_cells
)
worksheet.column_dimensions[column_letter].width = min(maximum_length + 3, 35)
worksheet.row_dimensions[1].height = 25Lab Manual 8 — Format Headers, Values and Column Widths
9 minObjective
Turn a plain data dump into a report an engineer can actually read: frozen header, filters, formatted numbers and columns wide enough to show their values.
Procedure
- Apply the freeze pane and auto filter first, then reopen the file to confirm both.
- Apply the header fill and bold font.
- Apply the
0.00number format to columns G to N and the date format to column A. - Run the width loop and check that no column shows
#####.
What each line does
| Code | Meaning in this lab |
|---|---|
| worksheet.freeze_panes = "A2" | Everything above row 2 stays visible while scrolling, so the header never leaves the screen. |
| worksheet.auto_filter.ref = worksheet.dimensions | Adds filter dropdowns across the whole used range, so the reader can filter by furnace or stage. |
| PatternFill(fill_type="solid", fgColor="F4B183") | Header background colour. fill_type must be set or the colour will not appear. |
| worksheet[1] | Row 1 — the header row. worksheet["A"][1:] is column A from row 2 down, skipping the header. |
| cell.number_format = "0.00" | Excel display format, two decimals. It changes how the number is shown, not the stored value. |
| numeric_columns = ["G", ... "N"] | Hard-coded letters. They are only correct while the SELECT column order and index=False stay unchanged. |
| min(maximum_length + 3, 35) | Width fits the longest value plus padding, capped at 35 so one long text column cannot dominate the sheet. |
| row_dimensions[1].height = 25 | Gives the header room so the centred text is not cramped. |
Copy-paste practice code
from openpyxl import load_workbook
workbook = load_workbook(excel_file)
worksheet = workbook["Event Report"]
print("Freeze panes :", worksheet.freeze_panes)
print("Auto filter :", worksheet.auto_filter.ref)
print("Header height:", worksheet.row_dimensions[1].height)
header = worksheet["A1"]
print("Header bold :", header.font.bold)
print("Header fill :", header.fill.fgColor.rgb)
print("\nColumn widths:")
for letter in ("A", "B", "C", "F", "H"):
width = worksheet.column_dimensions[letter].width
print(f" {letter}: {width:.1f}")
print("\nNumber format of H2:", worksheet["H2"].number_format)
workbook.close()This reads the formatting back out of the saved file. It is the check to run before emailing a report to a customer.
Expected result
Freeze panes : A2
Auto filter : A1:O101
Header height: 25.0
Header bold : True
Header fill : 00F4B183
Column widths:
A: 22.0
B: 11.0
C: 9.0
F: 20.0
H: 11.0
Number format of H2: 0.00Checkpoint
A2, the filter covers the full range, the header is bold and filled, and H2 reports the 0.00 format.If it fails
| Symptom | What to check |
|---|---|
| The header colour does not appear | fill_type="solid" is missing. A fgColor alone does nothing. |
| Numbers still show many decimals | That column is text, not numeric. A number format has no effect on a string. |
| Formatting lands on the wrong columns | The SELECT order changed, or index=False was dropped so everything shifted one column right. |
| A column shows ##### | The width is too small for the value. Raise the cap of 35, or shorten the source text. |
| Formatting disappears after saving | The formatting ran outside the with pd.ExcelWriter(...) block, after the workbook was already written. |
9. Error Handling and Connection Cleanup
The program separates database errors, file-permission failures and other exceptions. finally closes the SQL Server connection in every path.
except pyodbc.Error as error:
print("SQL Server error:")
print(error)
except PermissionError:
print(
"Excel file permission error.\n"
"Close the Excel file and run the program again."
)
except Exception as error:
print("Program stopped:")
print(error)
finally:
if connection is not None:
connection.close()
print("SQL Server connection closed.")A locked workbook commonly triggers PermissionError when a fixed filename is reused. Unique names reduce that risk, but folder permissions, antivirus scanning, synchronization software or another process may still block creation.
Lab Manual 9 — Error Handling and Connection Cleanup
8 minObjective
Make the report fail with one readable line instead of a stack trace, and prove the SQL Server connection is released on every path including failure.
Procedure
- Run the report normally and confirm the closing message appears.
- Open the last report in Excel, run again, and confirm the
PermissionErrormessage. - Break the server name deliberately and confirm the SQL Server handler catches it.
- Confirm that in both failures the connection-closed message still prints.
What each line does
| Code | Meaning in this lab |
|---|---|
| connection = None | Set before the try, so finally can test it safely even when the connect call itself failed. |
| except pyodbc.Error | Catches driver, login and query failures specifically. |
| except PermissionError | The common real-world failure: the previous workbook is still open in Excel, so the new file cannot be written. |
| except Exception as error | Last-resort handler. It must come after the specific handlers, or it would swallow them. |
| finally: | Runs on success, on failure, and after an unhandled exit — so the connection is never left open. |
| if connection is not None: | Guards against calling close() on a connection that was never created. |
Copy-paste practice code
import pyodbc
BROKEN_SERVER = r"SOFTWELL\NOSUCHINSTANCE"
connection = None
try:
connection = pyodbc.connect(
f"DRIVER={{{DRIVER}}};"
f"SERVER={BROKEN_SERVER};"
f"DATABASE={DATABASE};"
"Trusted_Connection=yes;"
"TrustServerCertificate=yes;",
timeout=5,
)
print("This line should never run.")
except pyodbc.Error as error:
print("Handled cleanly.")
print("SQLSTATE:", error.args[0])
print("Message :", str(error.args[1])[:110])
finally:
if connection is not None:
connection.close()
print("Finally block reached — cleanup always runs.")Run this once so you recognise the SQLSTATE codes in a real failure. timeout=5 keeps the drill short instead of waiting for the default login timeout.
Expected result
Handled cleanly.
SQLSTATE: 08001
Message : [Microsoft][ODBC Driver 17 for SQL Server]Named Pipes Provider: Could not open a connection to SQL Server
Finally block reached — cleanup always runs.Checkpoint
If it fails
| Symptom | What to check |
|---|---|
| A full traceback still appears | The failing call sits outside the try block. |
| NameError: connection is not defined | connection = None was not set before the try. |
| The PermissionError handler never fires | It is listed after except Exception, which catches it first. Specific handlers must come first. |
| The script hangs instead of failing | No login timeout. Add timeout=15 to pyodbc.connect(). |
10. Complete Copy-Paste-Ready Program
r"""
STEP 05: SQL Server to Excel Report
SQL Server : SOFTWELL\WINCC
Database : SQF_DB
Table : dbo.tblEvent
Output : D:\SQF_Reports
Install once:
python -m pip install pyodbc pandas openpyxl
"""
from datetime import datetime
from pathlib import Path
import pandas as pd
import pyodbc
from openpyxl.styles import Alignment, Font, PatternFill
# ============================================================
# 1. SQL SERVER SETTINGS
# ============================================================
SERVER = r"SOFTWELL\WINCC"
DATABASE = "SQF_DB"
TABLE = "dbo.tblEvent"
DRIVER = "ODBC Driver 17 for SQL Server"
# Export the latest 100 records
TOP_RECORDS = 100
# Excel report folder
OUTPUT_FOLDER = Path(r"D:\SQF_Reports")
# ============================================================
# 2. CONNECT TO SQL SERVER
# ============================================================
connection = None
try:
connection_string = (
f"DRIVER={{{DRIVER}}};"
f"SERVER={SERVER};"
f"DATABASE={DATABASE};"
"Trusted_Connection=yes;"
"TrustServerCertificate=yes;"
)
connection = pyodbc.connect(connection_string, timeout=15)
cursor = connection.cursor()
print("=" * 65)
print("STEP 05 - SQL SERVER TO EXCEL REPORT")
print("=" * 65)
print("SQL Server connection successful.")
# ========================================================
# 3. CHECK WHETHER THE TABLE IS AVAILABLE
# ========================================================
cursor.execute("""
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'dbo'
AND TABLE_NAME = 'tblEvent'
""")
table_available = cursor.fetchone()[0]
if table_available == 0:
raise RuntimeError("Table dbo.tblEvent is not available.")
print("Table dbo.tblEvent verified.")
# ========================================================
# 4. READ THE LATEST 100 RECORDS
# ========================================================
select_query = f"""
SELECT TOP ({TOP_RECORDS})
DT, TM, SQF_No, ChargeNo,
Event_From, Event_To,
Temp_Set, Temp_Act,
Cp_Set, Cp_Act,
Oil_Set, Oil_Act,
Jacket_Set, Jacket_Act,
Fan_Status
FROM dbo.tblEvent
ORDER BY DT DESC, TM DESC
"""
cursor.execute(select_query)
# Read SQL column names
column_names = [column[0] for column in cursor.description]
# Read SQL rows
rows = cursor.fetchall()
if not rows:
raise RuntimeError("No records are available in dbo.tblEvent.")
# Convert SQL records into a pandas DataFrame
dataframe = pd.DataFrame.from_records(rows, columns=column_names)
print(f"{len(dataframe)} records received from SQL Server.")
# ========================================================
# 5. CREATE A UNIQUE EXCEL FILE NAME
# ========================================================
OUTPUT_FOLDER.mkdir(parents=True, exist_ok=True)
# Date, time, seconds and microseconds prevent overwriting
timestamp = datetime.now().strftime("%d-%m-%Y_%H-%M-%S-%f")
excel_file = OUTPUT_FOLDER / f"SQF_Event_Report_{timestamp}.xlsx"
# ========================================================
# 6. WRITE SQL DATA INTO EXCEL
# ========================================================
with pd.ExcelWriter(
excel_file,
engine="openpyxl",
datetime_format="DD-MM-YYYY HH:MM:SS",
) as writer:
dataframe.to_excel(
writer,
sheet_name="Event Report",
index=False,
)
worksheet = writer.sheets["Event Report"]
# Freeze the heading row
worksheet.freeze_panes = "A2"
# Add filters to all columns
worksheet.auto_filter.ref = worksheet.dimensions
# Format the header
header_fill = PatternFill(fill_type="solid", fgColor="F4B183")
for cell in worksheet[1]:
cell.font = Font(bold=True)
cell.fill = header_fill
cell.alignment = Alignment(
horizontal="center",
vertical="center",
)
# Format the date column
for cell in worksheet["A"][1:]:
cell.number_format = "DD-MM-YYYY HH:MM:SS"
# Format the process-value columns
numeric_columns = [
"G", "H", # Temperature
"I", "J", # CP
"K", "L", # Oil
"M", "N", # Jacket
]
for column_letter in numeric_columns:
for cell in worksheet[column_letter][1:]:
cell.number_format = "0.00"
cell.alignment = Alignment(horizontal="center")
# Automatically adjust the column widths
for column_cells in worksheet.columns:
column_letter = column_cells[0].column_letter
maximum_length = max(
len(str(cell.value)) if cell.value is not None else 0
for cell in column_cells
)
worksheet.column_dimensions[column_letter].width = min(
maximum_length + 3,
35,
)
worksheet.row_dimensions[1].height = 25
# ========================================================
# 7. DISPLAY THE SUCCESS MESSAGE
# ========================================================
print("-" * 65)
print("Excel report created successfully.")
print(f"Excel file: {excel_file}")
print("=" * 65)
# ============================================================
# 8. ERROR HANDLING
# ============================================================
except pyodbc.Error as error:
print("SQL Server error:")
print(error)
except PermissionError:
print(
"Excel file permission error.\n"
"Close the Excel file and run the program again."
)
except Exception as error:
print("Program stopped:")
print(error)
# ============================================================
# 9. CLOSE THE SQL SERVER CONNECTION
# ============================================================
finally:
if connection is not None:
connection.close()
print("SQL Server connection closed.")Lab Manual 10 — Run the Complete Report Generator End to End
12 minObjective
Execute the finished program on a clean machine so the whole workflow — connect, verify, query, DataFrame, workbook, formatting — runs from a single command and produces a delivered file.
Procedure
- Create a project folder and a virtual environment so lab packages stay isolated.
- Install
pyodbc,pandasandopenpyxlinside it. - Save the Section 10 listing as
sql_to_excel_report.py. - Edit only five constants:
SERVER,DATABASE,TABLE,DRIVERandOUTPUT_FOLDER. - Run it, then open the generated workbook and check the header, filters and number formats.
What each line does
| Code | Meaning in this lab |
|---|---|
| python -m venv .venv | Creates an isolated environment so this lab never disturbs other projects on the PC. |
| .venv\Scripts\activate | Activates it on Windows. The prompt changes to show (.venv). |
| pip install pyodbc pandas openpyxl | All three are required. openpyxl is the Excel engine and is not installed with pandas by default. |
| TOP_RECORDS | Start at 20 on an unfamiliar database, then raise it to 100. |
| OUTPUT_FOLDER | Use a local folder for the first run. Move to a network share only once the script works. |
Copy-paste practice code
cd C:\SoftwellLabs\python-sql
python -m venv .venv
.venv\Scripts\activate
python -m pip install --upgrade pip
python -m pip install pyodbc pandas openpyxl
python sql_to_excel_report.pyOn macOS or Linux, activate with source .venv/bin/activate, install the Microsoft ODBC driver for your distribution, and use a POSIX path for OUTPUT_FOLDER.
Expected result
=================================================================
STEP 05 - SQL SERVER TO EXCEL REPORT
=================================================================
SQL Server connection successful.
Table dbo.tblEvent verified.
100 records received from SQL Server.
-----------------------------------------------------------------
Excel report created successfully.
Excel file: D:\SQF_Reports\SQF_Event_Report_19-09-2026_12-44-07-481293.xlsx
=================================================================
SQL Server connection closed.Checkpoint
If it fails
| Symptom | What to check |
|---|---|
| ModuleNotFoundError: No module named 'openpyxl' | The virtual environment is not active, or pip installed into a different Python. Run where python and confirm it points inside .venv. |
| No records are available in dbo.tblEvent | The table is empty. Run the insert practical (Code 02) first. |
| Excel file permission error | The previous report is open in Excel. Close it and run again. |
| The report opens but looks unformatted | The formatting block ran outside the with pd.ExcelWriter(...) context and was never saved. |
| It runs but you cannot find the file | The console prints the full path. Copy it from there rather than guessing the folder. |
11. Verify the Report and Improve It
Acceptance checks
- The printed file path exists and ends in
.xlsx. - The workbook opens without a repair warning.
- The sheet is named
Event Report. - The header row stays visible while scrolling and all columns have filters.
- The record count matches the SQL query result.
- The first row is the newest event under the defined ordering.
- Date and process-value columns display the intended formats.
| Problem | Likely cause | Action |
|---|---|---|
| ODBC driver/server error | Driver, instance, firewall or permissions | Verify connection settings and Windows identity |
| Table unavailable | Wrong database/schema or setup not run | Create/verify SQF_DB and dbo.tblEvent first |
| No records available | Empty source table | Insert approved test/production data before reporting |
| PermissionError | Folder denied or workbook locked | Close the file and verify output-folder rights |
| Dates display as text/numbers | Source type or cell format mismatch | Inspect DataFrame dtypes and Excel number format |
| Slow/large workbook | Too many rows or per-cell formatting cost | Use chunking, bounded periods and optimized formatting |
Useful production upgrades
- Wrap execution in functions and a
__main__guard. - Parameterize a report date range, furnace number or charge number.
- Add a “Report Summary” sheet with generation time, filters and row count.
- Create an Excel Table for structured filters and styles.
- Validate the saved workbook by reopening it with openpyxl before distribution.
- Write to a temporary file and atomically rename it after successful completion.
- Log report ID, SQL criteria, row count, duration and output checksum.
Hands-On Lab: Generate and Validate the Furnace Report
Hands-on- Use approved SQL data and a writable training output folder.
- Install pyodbc, pandas, openpyxl and the ODBC driver.
- Confirm dbo.tblEvent contains test records.
- Estimated time: 25 minutes.
Verify connection and row order
Run the query with 20 rows and compare the first/last timestamps with SQL Server Management Studio.
Generate the workbook
Run the complete program and record the printed output path.
Inspect visual formatting
Open the workbook and test freeze panes, filters, date display, two-decimal values and column widths.
Prove failure handling
Test an invalid output folder or controlled locked-file scenario, then restore the valid configuration.
Related Python and SQL Server Tutorials
Continue through the Softwell Python–SQL Server learning path:
- Python modules, classes and objects
- Python–SQL Server data type mapping
- Create a SQL Server database and table with Python
- Insert data into SQL Server with Python
- Read SQL Server data into pandas
- Complete Python SQL Server CRUD tutorial
- Export SQL Server data to Excel
- Create a Python EXE for WinCC Excel reports
Frequently asked questions
Which libraries create this SQL-to-Excel report?
pyodbc reads SQL Server, pandas stores the result in a DataFrame, and openpyxl writes and formats the XLSX workbook.
How does the script prevent overwriting reports?
The filename includes date, time, seconds and microseconds. For concurrent distributed jobs, add a UUID or use atomic unique-file creation.
Why does Excel generation raise PermissionError?
The workbook or folder may be locked, the user may lack write permission, or synchronization/security software may be holding the path. Close the file and verify folder access.
Do I need Microsoft Excel installed to run this script?
No. openpyxl writes the xlsx file directly, so the script runs on a server with no Office installation. Excel is only needed to open the finished report.
Why is the index column appearing in my workbook?
index=False was omitted from to_excel(). The pandas index then becomes column A and every formatting column letter shifts one place to the right.
My numbers show as text and the 0.00 format does nothing. Why?
That column arrived from SQL as text, so Excel stores a string. A number format only affects numeric cells. Convert with pd.to_numeric() before writing.
Where should the formatting code sit?
Inside the with pd.ExcelWriter(...) block, using the worksheet from writer.sheets. Formatting applied after the block closes is never saved.
How do I schedule this report every shift?
Use Windows Task Scheduler pointing at the virtual environment's python.exe, with a least-privilege SQL login that has SELECT only, and redirect the console output to a log file.
How do I add a second sheet, such as a summary?
Call to_excel() again inside the same with block with a different sheet_name, then fetch that sheet from writer.sheets to format it.