The following is my attempt at creating an easy on-ramp for busy engineers. If you’ve had any passing interest in learning python or ETABS API over the past year, let this post be your first step.
This tutorial is completely free. I’ve made a deliberate effort in scaffolding topics to be sequential and easy-to-follow: starting from the basics (installing python) to running a full script that interacts with ETABS by the end. I will assume zero prior knowledge. Whether you are a first-year student, or a senior principal, I hope you will get something out of this tutorial. AI has completely flattened the learning curve for everything1, but you still need to master the basics and learn the vocabulary. After that, let your curiosity do the rest.
My opinion is that these two technologies will be critically important in the coming decade2 3. Any time spent learning them will not go to waste. You’ll be able to automate tasks, do cool things that look like magic to others, and get a nice career boost, not to mention it’s kind of fun.
There are many different ways to work with python. I recommend the following for beginners:
I believe this is the most direct and pedagogically optimal combination. Other options may be more efficient, but they come at a cost of having layers of abstraction that may confuse rather than elucidate. As you become more proficient, you may wish to explore other options (e.g. vscode, pycharm, poetry, uv, conda). Here’s the step-by-step instruction for installing python:



py and hit enter to open a Python console. If you see this screen, you are ready to code. Try typing something like print("hello") or 1 + 1.

exit() or press CTRL+Z and press enter.py install 3.13 default. The default flag makes 3.13 the default version when using py.Python is cool by itself, but what makes it amazing is the vast collection of free and open-source packages (e.g. numpy, pandas, comtypes, openseespy, etc.). Rather than installing all your packages to your global python installation, the best practice is to set up separate environments for each project. Let’s go through that next.
Anytime you start a new python project, the first thing you should do is to create a virtual environments (venv). Think of a venv as a sandboxed copy of python that lives in your project folder. Here’s an illustration of what I mean:
Python virtual environment (venv) and pip package manager
Instruction for setting up a virtual environment and installing packages:
cmd in the windows explorer address bar.py -m venv my_first_venv. You can name it anything you like. Most people go with “venv” or “.venv”. This command will create a new folder in your project directory called “my_first_venv”. Inside, you will find a copy of python as well as any packages you might install later.my_first_venv\scripts\activate (note the left-leaning backslash “\” on Windows). Now you should see a (my_first_venv) prefix in your terminal.pip install numpy pandas comtypes matplotlib spyder-kernels.
One last thing thing: before we start coding, we’ll need an integrated developer environment (IDE) - a fancy word for text editor with extra features.
Python is a general-purpose programming language, which means there are many ways to work with it.
For this tutorial, we will use a very beginner-friendly IDE called Spyder. I like Spyder because it is simple and has the best variable explorer - which is important because it makes coding more approachable and feels less like a black box. Spyder also resembles MATLAB which is a bonus for those of us who learned it in school.
I purposefully picked a barebone IDE without any AI features. Our goal is to learn something new. Don’t let AI write any code for you at this time1. Don’t go the gym with a forklift. There are no deadlines and you are not subject to some productivity metric.



import numpy
import pandas
import comtypes.client
CONSTANT_A = 386.1
def main():
print("hello world! Our script starts here")
answer = add_function(a=1, b=1)
print(f"1 + 1 is equal to: {answer}")
return answer
def add_function(a, b):
"""This function returns the sum of a and b"""
var = a + b
return var
# Boiler plate code to call main() and avoiding global namespace pollution.
if __name__ == '__main__':
MAIN_RETURN = main()
There are plenty of excellent python tutorials on the internet, I have no desire in duplicating this effort, and I doubt I have much to add. I recommend going through a few courses on Coursera, Udemy, or YouTube. After that, my advice is to quickly transition to project-based learning. Try to shoehorn python into everything you do: from personal project to tasks at work. If something is boring and repetitive, see if you can automate it with python.
I’ve included my personal Python cheat sheet below. You should also make your own.
Installation and Project Setup
py install 3.13 - install pythonpy - start python (default version)py -3.11 - start python (specific version)setx PYTHON_MANAGER_DEFAULT "3.13" - set default versionspy list - show all installed python on your computerpy -m venv .venv - create a virtual environment called .venv.venv\scripts\activate - activate venvpip install numpy - installing packages (e.g. numpy)pip list - show all installed packagespip freeze > requirements.txt - save your a comprehensive package list to requirement.txtpip install -r requirements.txt - install packages listed in a requirement.txtFundamentals
"""
Multi-line comment with triple quote
"""
# Single-line comment with pound sign (#)
# Importing modules and packages
import numpy as np
import math
# Variable assignment
a = int(42) # integer
b = 3.14 # float
c = "hello world" # string
# List
my_list = [1, 2, 3] # define a list of elements
first = my_list[0] # return the first element (0-based indexing)
# Dictionary
my_dict = { # define a dictionary
"key": "value",
"key2": "value2",
"key3": "value3"
}
f = my_dict["key2"] # return value associated with key2
# Math operations
a+b, a-b, a*b, a/b, # basic algebra
a**2, # exponent using **
a//b, # floor division 5//2=2
a%b, # modulo (remainder) 5%2=1
abs(a), # absolute value
max([a,b]), # max
min([a,b]), # min
math.sin(b), # comes with math module. Similar for cos, tan, atan, etc.
math.sqrt(b), # or just use x**(1/2)
math.isclose(a,b), # never compare two floats with ==. Use this instead
# Printing to console
print("hello world")
print(f"value of a = {a}, and b = {b}")
print("alternative method: value of a = {} and b = {}".format(a,b))
print("insert line break with \n and tab with \t")
Flow Control
# Use 'pass' when you need a blank filler that doesn't do anything
pass
# If statement
if condition:
pass
elif:
pass
else:
pass
# While loops
while condition:
pass
# For-loop with index
n = len(my_list)
for i in range(n):
print(my_list[i])
# For-loop by element
for element in my_list:
print(element)
# Raising exception when you detect an error
if ret_vaue != 0:
raise RuntimeError('Stopped code. Detected some error.')
Function
# Defining a function
def someFunction(arg1, arg2, default_arg1=3.14):
"""Doc string explains what this function does, input arg, and return values"""
a = 1 + 1
return a
# Calling a function
ret_value = some_function(arg1 = 1, arg2 = 2)
# Asking user for input
user_input = input("User can enter a value. Returns as a string")
Lists
# Defining lists
my_list = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]
matrix = [[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]]
# List indexing. Python has 0-based indexing. 0 is first element.
first = my_list[0] # 0
fourth = my_list[3] # 30
last = my_list[-1] # 90
second_list = my_list[-2] # 80
# List Slicing [start:stop:step]
middle_slice = my_list[2:5] # [20, 30, 40]
seven_to_end = my_list[7:] # [70, 80, 90]
up_to_3 = my_list[:3] # [0, 10, 20]
exclude_last_4 = my_list[:-4] # [0, 10, 20, 30, 40, 50]
# Advanced indexing
a = my_list[-2] # returns second last element = 80
a = my_list[::-1] # reverses the list [90, 80, 70, 60, 50, 40, 30, 20, 10, 0]
# Find index of an element you're looking for
idx = my_list.index(8) # Find first instance
# Extending a list
my_list.append('new item')
list1.extend(list2) # Splice list2 to the end of list1 in place
list3 = list1 + list2 # Returns same as above
list1.insert(idx,val) # Val is assigned to idx index. Everything else pushed back
# Removing elements from list
my_list.remove(element) # Remove first instance found
del my_list[0:4] # Remove a slice from list
# Other useful operations
my_list.sort() # Sort in increasing order in place
sorted_list = sorted(my_list) # Return sorted list
my_list.reverse() # Reverse a list in place
reversed_list = my_list[::-1] # Return a reversed list
# List comprehension - automatically apply transformations to a list in one-line
squared_list = [x*x for x in my_list2]
filtered_list = [x for x in my_list2 if x>2]
product_list = [a*b for a,b in zip(my_list1, my_list2)]
String
# Strings are just list of characters, and can be indexed the same way
mystr = "abcdefghijklmn"
mystr[1] # returns b
mystr[4:8] # returns efgh
mystr[:4] # returns abcd (first 4 letters)
mystr[4:] # returns efghijklmn (everything after first 4 letters)
mystr[-4:] # returns klmn (last 4 letters)
"""
The trick is to picture index on the left of char. Imagine boundary line on left
a b c d e f g h
0 1 2 3 4 5 6 7 8
We want to make cut at 4 and 8. Leaving us efgh
Since string is just a list of characters. The above operation also works on lists
"""
# Other useful string operations for parsing
concat = str1 + str2 # concatenate strings
"word" in string # checks if "word" is in the string. Returns boolean
string.find("word") # find the index where "word" occurs. Returns -1 if failed
string.strip() # removes spaces and \n \t
string.strip(",.abc:;") # remove occurence of these characters
string.split(" ") # Split into list of strings at white space
string.split(",") # Split into list of strings at comma or any other character
string.count("word") # count how many times substring "word" occured
string.uppercase() # convert all to uppercase
string.lowercase() # convert all to lowercase
string.swapcase() # swap lower and upper case. Vice versa
string.startwith("2020") # see if string starts with prefix "2020"
string.endswith(".jpeg") # see if string ends in suffix ".jpeg"
Dictionary
# Dictionaries are key-value Pairs. Key must be unique, value doesn't have to be
# Creating a dictionary
my_dict = dict()
my_dict['key1'] = 123
my_dict['key2'] = "abcd"
# or define with multiple lines
my_dict = {
"key1": 123,
"key2": "abcd"
}
# Accessing a dictionary
myval = my_dict['key1'] # returns "abcd"
# Loop through dictionaries. Order is NOT guaranteed.
for key in my_dict.keys():
pass
for value in my_dict.Values():
pass
for k,v in my_dict.items():
pass
# Other useful methods
my_dict.items() # Return tuples of all key-value pair
my_dict.values() # Return all values
my_dict.keys() # Return all keys
'key' in dict # Check if key is in dict
my_dict[key1]=None # If you want to disassociate a value to key
my_dict.pop(key) # Remove entire key-value pair
# Convert JSON to python dictionary and vice versa
import json
with open("read.json", "r") as file:
my_dict = json.load(file)
with open("write.json", "w") as file:
json.dump(my_dict, file)
Tuples and Sets
# Tuple are just lists that cannot be modified nor appended
myTuple = (1, 2, 3)
# Tuple unpacking - assigning multiple variables with one-line
var1, var2, var3 = 1, 2, 3
def func():
return a,b
var1, var2 = func()
# Sets are list, but they contain only unique entries
my_list = [1, 1, 2, 2, 3]
myset = set(my_list) # equals {1, 2, 3}
# Can use intersection of sets or union of sets
item_in_both = setA & setB # Intersection
item_in_either = setA | setB # Union
# Other methods
set1.add() # Append value to set
set1.difference(set2) # Returns value that only occur in set1 but not set2
set1.issubset(set2) # Check if set1 is subset of set2
set1.issuperset(set2) # Check if set1 is superset of set2
set1.discard(set2) # Discard element from set1 if it exists in set 2
File IO
import os
# When not specifying any path, file is assumed to be in working directory
pd.read_csv("data.csv")
# When specifying abs path, always convert to raw with 'r' prefix
file_path = r"C:\Users\wcfro\Python\snippets"
# Get current working directory
os.getcwd()
# Get folder path where .py file is located. This is more robust than getcwd()
os.path.dirname(os.path.abspath(__file__))
# Make a new directory
os.mkdir("new_folder")
# Check if directory exists
os.isdir()
# List out all files in the folder
file_list = os.listdir(my_path)
# Get All files of a specific format
png_list=[]
for f in file_list:
if f.endswith(".png"):
png_list.append(f)
# Return parent folder "C:\Users\wcfro\Python\snippets"
os.path.dirname(r"C:\Users\wcfro\Python\snippets\file.csv")
# Can call dirname() recursively to get the parent folders n levels up
# This returns "C:\Users\wcfro\Python"
os.path.dirname(os.path.dirname(r"C:\Users\wcfro\Python\snippets\file.csv"))
# Joining path
file_path = os.path.join(os.getcwd, "scripts", "file.csv")
# Reading txt file with iostream
with open('file.txt', 'r') as f:
firstline = next(f) # skip first line
secondline = next(f) # skip to second line
for line in f: # Loop through all other lines
split_data = line.split() #split line
Col1.append(float(split_data[0])) #extract column 1 data
Col2.append(float(split_data[1])) #extract column 2 data
Col3.append(float(split_data[2])) #extract column 3 data
# Alternatively, read entire file in one go. Get a list of all lines
with open('file.txt', 'r') as f:
linesdata = f.readlines()
# Writing to File. Good for texts. Recommend using libraries for csv (e.g. pd.to_csv())
with open("my_output",'w') as f:
for items in mylist:
f.write('{},{},{}\n'.format(items[0],items[1],items[3]))
Let’s do a practice problem: write a python function that calculates the flexural capacity of singly-reinforced concrete sections (\(\phi M_n\)). Below is the starter code. Write your logic inside calculate_moment_capacity() function. Give it a try without looking at the solution below.
Starter Code:
def calculate_moment_capacity():
"""
This function calculates the moment capacity of a rectangular
reinforced-concrete beam
"""
# -------------------
# YOUR CODE HERE
# -------------------
return phi_Mn
# calculate moment capacity
phi_Mn = calculate_moment_capacity()
# print out results
print("Design Moment Capacity = {:.2f}".format(phi_Mn))
If you need a refresher on reinforced-concrete beam design, here’s all the theoretical background you’ll need to implement it in python.

The full expression for moment capacity:
\[\boxed{M_n = f_yA_s (d-\frac{f_yA_s}{2 (0.85) f'_c b})}\]Neutral axis depth:
\[c = a / \beta\] \[\beta = max(0.65, min(0.85, 0.85-\frac{0.05(f'_c-4000)}{1000}))\]Crushing strain of concrete:
\[\epsilon_{cu} = 0.003\]Yielding strain of rebar (grade 60):
\[\epsilon_{ty} = 0.002\]To determine the strength reduction factor (\(\phi\)), calculate net tensile strain based on the curvature. At ultimate condition, we can use the expression below:
\[\epsilon_t = \frac{\epsilon_{cu}}{c} (d-c)\]The strength reduction factor (\(\phi\)) can be one of three values:
Solution:
def calculate_moment_capacity(b, d, As, fpc, fy):
"""
This function calculates the moment capacity of a rectangular
reinforced-concrete beam using ACI 318.
Args:
b float:: section width (inches)
d float:: section depth (inches)
As float:: area of steel (in^2)
fpc float:: concrete strength (ksi)
fy float:: rebar strength (ksi)
Return:
phi_Mn float:: design moment capacity (k.in)
"""
# calculate beta factor
if fpc <= 4:
beta = 0.85
elif fpc >= 8:
beta = 0.65
else:
beta = 0.85 - 0.05*(fpc - 4)
# calculate neutral axis and depth of rectangular stress block
a = fy * As / 0.85 / fpc / b
c = a/beta
# Calculate resistance factor
e_yield = fy/29000
et = 0.003*(d - c)/c
if et > 0.005:
phi = 0.9 # tension-controlled
elif et < e_yield:
phi = 0.65 # compression-controlled
else:
phi = 0.65 + (0.25*(et - e_yield))/(0.005-e_yield) # transition zone
# calculate flexural capacity
Mn = fy*As*(d - a/2)
phi_Mn = phi * Mn
return phi_Mn
# User input
fy = 60 #ksi
fpc = 4 #ksi
b = 12 #in
h = 24 #in
cover = 2 #in
rebar_count = 4
rebar_size = "#6"
# Rebar information stored in a dictionary
REBAR_DICT = {
"#3": {"area":0.11, "diameter":0.375},
"#4": {"area":0.20, "diameter":0.500},
"#5": {"area":0.31, "diameter":0.625},
"#6": {"area":0.44, "diameter":0.750},
"#7": {"area":0.60, "diameter":0.875},
"#8": {"area":0.79, "diameter":1.000},
"#9": {"area":1.00, "diameter":1.128},
"#10": {"area":1.27, "diameter":1.270},
"#11": {"area":1.56, "diameter":1.410},
}
# calculate rebar depth and area outside
d = h - cover - REBAR_DICT[rebar_size]["diameter"]/2
As = rebar_count * REBAR_DICT[rebar_size]["area"]
# calculate moment capacity
phi_Mn = calculate_moment_capacity(b = b,
d = d,
As = As,
fpc = fpc,
fy = fy)
# print out results
print(f"{b} in x {h} in concrete beam with ({rebar_count}) {rebar_size} bottom:")
print("Design Moment Capacity = {:.2f} k.in".format(phi_Mn))
This exercise covers the fundamentals of python all within the familiar context of engineering calculations. Furthermore, the problem encourages system thinking. Every problem we encounter day-to-day can be solved in an infinite number of ways. How do we find one that is sensible, amenable to future needs, and balances tradeoffs?4
Want more practice? Try repeating the exercise above with an engineering calculation you most often encounter at work. For example: NDS timber design, plate bending, column buckling, etc.
There are two ways of connecting to the ETABS API: pythonnet (NET) and comtypes (COM). comtypes is more reliable as of 2026. Pythonnet is newer but it forces pre-declaration of variables and static typing which is unnecessarily verbose for quick scripts.
The easiest and most common use case is to directly attach to an active ETABS model (that you have open in another window).
To get started, open your ETABS model.
On the top menu bar, click “Tools”. If you see a greyed out: “Active instance for API (Process ID: xxxxx)” it means the program is ready to interact with the API.

Let’s do a test run. Copy the code below into Spyder and run it.
import comtypes.client
def connect_to_ETABS():
"""
Attach to an currently open ETABS instance using comtypes.
"""
helper = comtypes.client.CreateObject('ETABSv1.Helper')
helper = helper.QueryInterface(comtypes.gen.ETABSv1.cHelper)
try:
myETABSObject = helper.GetObject("CSI.ETABS.API.ETABSObject")
except (OSError, comtypes.COMError):
print("No running instance of the program found or failed to attach.")
SapModel = myETABSObject.SapModel
return SapModel
# Connect to ETABS
SapModel = connect_to_ETABS()
# Set ETABS API unit (this is independent from your model)
SapModel.SetPresentUnits(3) # KIP IN
# Return the ETABS version
ret1 = SapModel.GetprogramInfo()
# Return the model file path
ret2 = SapModel.GetModelFilepath()
# Return all the frame objects in your model
ret3 = SapModel.FrameObj.GetAllFrames()
In the code block above, I’ve wrapped bunch of boiler plate code that establishes connection to ETABS into a function. In the future, just call SapModel = connect_to_ETABS() to get started.
In addition, I called four API commands:
SapModel.SetPresentUnit - set the unit used by ETABS API. Note PresentUnit does not affect your model. It is used for data transfer purposes only. Refer to ETABSv1.eUnits for unit enumeration. 3 = KIP_IN_F, 4 = KIP_FT_F, 6 = kN_m_C, etc.SapModel.GetprogramInfo() - returns information about the ETABS version, which for me is a trial version of ETABS Ultimate v23.0.0. (I have to finish this blog in the next 30 days).SapModel.GetModelFilepath() - returns the model save file path.SapModel.FrameObj.GetAllFrames()) - returns a data dump of all frame elements in the model. If we click on theret3 variable, we see an expanded list. The ability to see variables so clearly is what makes Spyder so awesome and beginner-friendly.If everything worked as expected, you should see several return variables (ret1, ret2, ret3) in the variable explorer:

And you should see something like when after clicking on ret3.

What do these numbers mean? Where can I find more API commands? Let’s go through this in the next section.
All API commands can be found in the documentation which is located in your ETABS installation folder. The file path looks something like this:
C:\Program Files\Computers and Structures\ETABS 23\CSI API ETABS v1.chm

Here’s the welcome screen:

The manual is quite lengthy and overwhelming for first-time readers. Let’s navigate it together.

SapModel.AreaObjSapModel.FrameObjSapModel.PointObjSapModel.AnalysisResultsSapModel.DatabaseTablesSapModel.SetPresentUnits()SapModel.GetprogramInfo()SapModel.GetModelFilePath()SapModel.FrameObj.GetAllFrames()One pattern you should have noticed by now is the prevalence of dot notation. Every API command is accessed by SapModel.___. The “SapModel” pointer object is our connection to ETABS:

Let’s take a look at the SapModel.cAreaObj.AddByCoord() page and work through it together. As the name suggests, this command allows us to add a new area elements to the model by specifying vertices (x, y, z) coordinates. Here it is:

In general, there are three sections to look for on every documentation page:
python is not available yet as of April 2026. I usually look at syntax for C#. It’s close enough and gives me a good idea of what inputs are needed.ref prefix means pass-by-reference. This is a major source of confusion and warrants further discussion. Passing by reference is akin to sending someone the URL to an object - the caller and callee has the memory address to the same variable; whereas, passing by value makes a whole separate copy. In practice, this means “ref” parameters are often passed in as empty variables. In C# or VBA, we must pre-declare these variables, and then have the API modify them by reference.

SapModel.FrameObj.GetAllFrames() command we invoked earlier. Notice how we have to pre-declare everything in C#. Whereas in python, everything is returned in a big list.
ref arguments are returned as well.Going back to the SapModel.AreaObj.AddByCoord() example, we can call the method like this:
NumberPoints, X, Y, ZNamePropName="Default", UserName="", CSys="Global"[X, Y, Z, name, integer_flag]# Connect to ETABS
SapModel = connect_to_ETABS()
# Set unit to kip inches
SapModel.SetPresentUnits(3)
# Specify vertices
x = [50, 100, 150, 100, 50, 0]
y = [0, 0, 40, 80, 80, 40]
z = [0, 0, 0, 0, 0, 0]
# Add area object by coord
ret = SapModel.AreaObj.AddByCoord(NumberPoints=6, X=x, Y=y, Z=z)
# Refresh view
SapModel.View.RefreshView()
If everything worked correctly, you should see this in ETABS:

And the API should have returned a list of 5 elements to Python:

That’s all you need to know to navigate the API documentation! Take a few more minutes to browse around. See if you can add a new joint or frame member using the PointObj or FrameObj interface, respectively. Next, let’s cover two common ETABS API usage patterns.
If you have no interest in learning ETABS API, then Database Table is the right interface for you. I call this the big data-dump method. If all you want is to extract data from ETABS, then all you need is this single API command: SapModel.DatabaseTables.GetTableForDisplayCSVString(). For years, this was the only command I knew. You’d be surprised how much you can get done with this just Database Tables.
Rather than extracting or modifying data for specific elements, we can extract or manipulate tabular data in bulk. For example, rather than getting the reaction forces at Joint 3, we can extract the entire “Joint Reactions Table”, and then do all our post-processing in Python.
Here’s an illustration of what I mean:

To see what Database Table you have access to, use the command below. You can also check by pressing CTRL+T in the GUI.
# Show all available tables
ret = SapModel.DatabaseTables.GetAvailableTables()
To get a specific database table. There are also array and file export options, but I think csv is the easiest to work with.
ret = SapModel.DatabaseTables.GetTableForDisplayCSVString()
There are three points of inconvenience when reading Database Tables:
dataframes.import pandas as pd
import io
# Connect to ETABS (we defined this function in Section 3.1)
SapModel = connect_to_ETABS()
# Always remember to set unit. Let's use kip inches
SapModel.SetPresentUnits(3)
# Set load combination and load case
selected_case = ["Dead", "Live"]
selected_combo = ["1.2D + 1.6L"]
SapModel.DatabaseTables.SetLoadCasesSelectedForDisplay(selected_case)
SapModel.DatabaseTables.SetLoadCombinationsSelectedForDisplay(selected_combo)
# Get database table as csv string
table_name = "Joint Reactions"
ret = SapModel.DatabaseTables.GetTableForDisplayCSVString(TableKey = table_name,
GroupName = "All")
# Convert csv string to Dataframe
csv_string = ret[2]
csv_io = io.StringIO(csv_string)
df_data = pd.read_csv(csv_io, dtype=str)
# Coerce data into numeric if possible
for column in df_data.columns:
try:
df_data[column] = pd.to_numeric(df_data[column])
except:
pass
For ease of use, I recommend wrapping the logic above into a function called get_database_table(). Also add some error handling. I’ll leave this as an exercise for the reader. Here’s the “Joint Reaction” table for my model:

You can also make changes to your model with tabular data using a feature in ETABS called Interactive Database. This is actually the fallback command that pretty much does anything you could possibly want. ETABS API does not have 100% coverage. There are still certain things that can only be done through the GUI or through interactive database (e.g. defining grids). It’s important to highlight that “Database Table” is NOT the same as “Interactive Database Table”.
SapModel.DataBaseTables.GetTableForDisplayCSVString()CTRL + TSapModel.DataBaseTables.GetTableForEditingCSVString()CTRL + EEditing model using Interactive Database through the API follows a four-step process:

My typical workflow is to convert to csv to dataframe first, modify it, then convert the dataframe back to CSV string. For example:
import pandas as pd
import io
# Connect to ETABS (we defined this function in Section 3.1)
SapModel = connect_to_ETABS()
# Always remember to set unit. Let's use kip inches
SapModel.SetPresentUnits(3)
# Step 1: Get editing table as csv string
table_name = "Load Combination Definitions"
ret = SapModel.DatabaseTables.GetTableForEditingCSVString(TableKey = table_name,
GroupName = "All")
table_version = ret[0]
csv_string = ret[1]
int_flag = ret[2]
# Convert to Dataframe
csv_io = io.StringIO(csv_string)
df_data = pd.read_csv(csv_io, dtype=str)
# Step 2: Manipulate Dataframe. Let's add another load combo
new_row = {"Name": "1.4D",
"Type": "Linear Add",
"Is Auto": "No",
"Load Name": "Dead",
"Mode": None,
"SF": 1.4,
"GUID": None,
"Notes": None
}
df_newrow = pd.DataFrame([new_row])
df_modified_data = pd.concat([df_data, df_newrow], ignore_index=True)
# Convert back to csvString
csv_modified_data = df_modified_data.to_csv(index=False)
# Step 3: Set table
ret = SapModel.DatabaseTables.SetTableForEditingCSVString(TableKey = table_name,
TableVersion = table_version,
csvString = csv_modified_data)
# Step 4: Push change to model
SapModel.DatabaseTables.ApplyEditedTables(True)
Interactive database has a tendency of corrupting your model if you are not careful with following the table structure. Remember to save a backup!
Solely relying on Database Tables is fine, but it’s not the best way to work with ETABS API. The more intuitive and scalable method is using what’s called a Object Relational Mapping. To use this method, you must first understand object-oriented programming (OOP). If you don’t know what that is, pause here and watch a few YouTube videos. OOP has bunch of scary terminologies, but it’s actually quite intuitive once you grok the key concepts.
In essence, rather than having a linear workflow where we work with tabular data, it’s much better to work with objects - where attributes mirror exactly what is in ETABS, and methods mirror exactly the API commands.

What we are creating is called a Rich Domain Model - a fancy word for how we converted ETABS data into a mirrored pythonic representation. By doing this, we can write incredibly readable code that scales incredibly well! This is best explained with an example:
With ETABS API, create a bubble plot of the joint base reactions (FZ) for your model.
Solution #1: Using Database Tables
import comtypes.client
import pandas as pd
import io
import matplotlib.pyplot as plt
#################################
# Step 1: Connect to ETABS
#################################
def connect_to_ETABS():
"""
A function for attaching to an currently open ETABS instance.
Feel free to copy this. In the future, just invoke
"connect_to_ETABS()" to connect.
"""
helper = comtypes.client.CreateObject('ETABSv1.Helper')
helper = helper.QueryInterface(comtypes.gen.ETABSv1.cHelper)
try:
myETABSObject = helper.GetObject("CSI.ETABS.API.ETABSObject")
except (OSError, comtypes.COMError):
print("No running instance of the program found or failed to attach.")
SapModel = myETABSObject.SapModel
return SapModel
# Connect to ETABS
SapModel = connect_to_ETABS()
#####################################
# Step 2: Extract reaction data
#####################################
# Set unit to kip inches
SapModel.SetPresentUnits(3)
# Set load combination and load case
selected_case = ["Dead"]
selected_combo = []
SapModel.DatabaseTables.SetLoadCasesSelectedForDisplay(selected_case)
SapModel.DatabaseTables.SetLoadCombinationsSelectedForDisplay(selected_combo)
# Get database table as csv string
ret = SapModel.DatabaseTables.GetTableForDisplayCSVString(TableKey = "Joint Reactions",
GroupName = "All")
# Convert csv string to Dataframe
csv_string = ret[2]
csv_io = io.StringIO(csv_string)
df_reactions = pd.read_csv(csv_io, dtype=str)
# Coerce data into numeric if possible
for column in df_reactions.columns:
try:
df_reactions[column] = pd.to_numeric(df_reactions[column])
except:
pass
#####################################
# Step 3: Extract point connectivity data
#####################################
# We will also need Point Connectivity Data for (x,y,z)
ret = SapModel.DatabaseTables.GetTableForDisplayCSVString(TableKey = "Point Object Connectivity",
GroupName = "All")
csv_string = ret[2]
csv_io = io.StringIO(csv_string)
df_coord = pd.read_csv(csv_io, dtype=str)
# Coerce data into numeric if possible
for column in df_coord.columns:
try:
df_coord[column] = pd.to_numeric(df_coord[column])
except:
pass
#####################################
# Step 4: Gather relevant reaction data and prep for plot
#####################################
# get all unique base joints
base_joints = df_reactions["Unique Name"].unique()
# get max and min reaction for color and bubble size scaling
df = df_reactions[df_reactions["Output Case"]=="Dead"]
reaction_min = min(df["FZ"])
reaction_max = max(df["FZ"])
size_min = 100
size_max = 2000
size_range = size_max - size_min
# loop through each joint and gather data for our plot
x = []
y = []
Fz = []
color = []
size = []
for unique_name in base_joints:
# query table to find the correct row for joint i
connectivity_data = df_coord[df_coord["UniqueName"] == unique_name]
reaction_data = df_reactions[df_reactions["Unique Name"] == unique_name]
# coordinate
x.append(connectivity_data["X"].iat[0])
y.append(connectivity_data["Y"].iat[0])
# reaction
Fz_data = reaction_data["FZ"].iat[0]
Fz.append(Fz_data)
# size
size_normalized = ((Fz[-1] - reaction_min) / size_range) * size_max + size_min
size.append(size_normalized)
# color
color.append(Fz_data)
#####################################
# Step 5: Create bubble chart
#####################################
# initialize plot
fig, axs = plt.subplots(figsize=(8.5, 11))
# plot bubbles
scatter = axs.scatter(x, y, s=size, c=color,
cmap="rainbow", alpha=0.6, edgecolors="black")
# add annotation
for i, unique_name in enumerate(base_joints):
axs.annotate(
Fz[i],
(x[i], y[i]),
fontsize=10,
ha='center',
va='top',
xytext=(0, 25),
textcoords='offset points'
)
# some basic plot formatting
fig.suptitle("Base Reaction Plot", fontsize=15)
axs.set_xlabel("X")
axs.set_ylabel("Y")
axs.grid(True, linestyle='--', alpha=0.6)
fig.colorbar(scatter, label="Fz (kips)")
fig.tight_layout()
Solution #2: Using Object-Oriented Programming:
import comtypes.client
import matplotlib.pyplot as plt
#####################################
# Step 1: Define a Joint class
#####################################
class JointObject:
"""
Object-relational mapping of Joint objects in ETABS.
"""
def __init__(self, unique_name):
self.unique_name = unique_name
self.x = None
self.y = None
self.z = None
self.is_restrained = None
self.Fz = None
def get_coords(self, SapModel):
"""get (x,y,z) coordinate of this joint"""
ret = SapModel.PointObj.GetCoordCartesian(Name=self.unique_name)
self.x = ret[0]
self.y = ret[1]
self.z = ret[2]
def get_restraint(self, SapModel):
"""determine if this joint is restrained or not"""
ret = SapModel.PointObj.GetRestraint(Name=self.unique_name)
restraint_bool = ret[0]
if True in restraint_bool:
self.is_restrained = True
else:
self.is_restrained = False
def get_reaction(self, SapModel):
"""determine dead load reaction at this joint (Fz)"""
# make sure we know if this is a restrained node
if self.is_restrained is None:
self.get_restraint(SapModel)
if self.is_restrained:
# Deselect all cases and combos
SapModel.Results.Setup.DeselectAllCasesAndCombosForOutput()
# Select "Dead" load case
SapModel.Results.Setup.SetCaseSelectedForOutput("Dead")
# extract reaction data
ret = SapModel.Results.JointReact(Name=self.unique_name, ItemTypeElm = 1)
self.Fz = round(ret[8][0], 0)
#################################
# Step 2: Connect to ETABS
#################################
def connect_to_ETABS():
"""
A function for attaching to an currently open ETABS instance.
Feel free to copy this. In the future, just invoke
"connect_to_ETABS()" to connect.
"""
helper = comtypes.client.CreateObject('ETABSv1.Helper')
helper = helper.QueryInterface(comtypes.gen.ETABSv1.cHelper)
try:
myETABSObject = helper.GetObject("CSI.ETABS.API.ETABSObject")
except (OSError, comtypes.COMError):
print("No running instance of the program found or failed to attach.")
SapModel = myETABSObject.SapModel
return SapModel
# Connect to ETABS
SapModel = connect_to_ETABS()
# Set unit to kip inches
SapModel.SetPresentUnits(3)
#################################
# Step 3: Work with joint objects
#################################
# get all joints in model
ret = SapModel.PointObj.GetAllPoints()
joint_names = ret[1]
# create point objects and store it in a list
all_joints = []
for i in range(len(joint_names)):
joint_obj = JointObject(unique_name = joint_names[i])
all_joints.append(joint_obj)
for joint in all_joints:
# get joint coordinate
joint.get_coords(SapModel)
# get joint restraint
joint.get_restraint(SapModel)
# get joint reactions
joint.get_reaction(SapModel)
# get relevant base joints
relevant_joints = [joint for joint in all_joints if joint.is_restrained]
#####################################
# Step 4: prep bubble plot size and color
#####################################
# get max and min reaction for color and bubble size scaling
reaction_min = min([joint.Fz for joint in relevant_joints])
reaction_max = max([joint.Fz for joint in relevant_joints])
size_min = 100
size_max = 2000
size_range = size_max - size_min
# gather data for our plot
x = [joint.x for joint in relevant_joints]
y = [joint.y for joint in relevant_joints]
Fz = [joint.Fz for joint in relevant_joints]
color = [joint.Fz for joint in relevant_joints]
size = [((joint.Fz - reaction_min) / size_range) * size_max + size_min for joint in relevant_joints]
#####################################
# Step 5: Create matplotlib bubble chart
#####################################
# initialize plot
fig, axs = plt.subplots(figsize=(8.5, 11))
# plot bubbles
scatter = axs.scatter(x, y, s=size, c=color,
cmap="rainbow", alpha=0.6, edgecolors="black")
# add annotation
for i, unique_name in enumerate(relevant_joints):
axs.annotate(
Fz[i],
(x[i], y[i]),
fontsize=10,
ha='center',
va='top',
xytext=(0, 25),
textcoords='offset points'
)
# some basic plot formatting
fig.suptitle("Base Reaction Plot", fontsize=15)
axs.set_xlabel("X")
axs.set_ylabel("Y")
#axs.set_aspect('equal', adjustable='box')
axs.grid(True, linestyle='--', alpha=0.6)
fig.colorbar(scatter, label="Fz (kips)")
fig.tight_layout()
Here’s my joint reaction bubble chart. It makes sense that reaction is lowest at corner columns and highest at interior columns.

Congratulations on reaching the end of the tutorial. I hope you learned a thing or two. Go make some new and exciting tools and share it with the world if you can. This post took me forever to write. Thank you for reading!
Footnotes:
Using AI to learn requires a delicate balance. You most definitely want to leverage AI to learn faster. Think of it as the most patient, most all-knowing, most readily accessible tutor in the world - one that knows your exact needs and can quickly pinpoint your source of confusion. Alexander the Great had Aristotle. You have something even better in your pocket. But at the same time, over-relying on AI can actually lead to the opposite outcome where you learn less and retain nothing. A good tutor teaches the student how to fish; AI companies promise to fish for you for $20 a month. Drilling exercise problems, getting confused, feeling stupid, trying and failing over and over are essential parts for forming the right neural connections in your head. I recommend identifying what is important to you and wrestle with it without AI. Do not outsource your thinking wholesale. AI is either making you smarter, or making you lazy. Always be cognizant of which side you are on. At the end of the day, you should be the one guiding AI agents, not the other way around. ↩ ↩2
Python is de-facto the best scripting language with the biggest collection of open-source packages. Excel is already bursting at the seams from modern computation demands. I don’t think Excel is going away, but it’s clear the heavier computation will shift to Python. Rhino/Grasshopper solves interesting geometrical problems, but it’s architects, not engineers. AI adoption will push more and more engineers towards python. ↩
ETABS API is important because it allows engineers (and AI agents) to interact with the FEM model directly with programming rather than with a graphical user interface. CSI had the incredible foresight to invest and develop an incredible API. Kudos to whoever pushed the API forward. I know startups that picked CSI products - despite the hefty price - over cheaper alternatives because of the API. All of that is to say: looking forward to Ashraf’s next $4 million party. If perchance you work at a competing software vendor, I invoke that famous Jeff Bezos quote: “your margin is my opportunity”. I would love to see more APIs in ths space. ↩
For example, here are some of the decisions one must make in this first example: should we pass in the number of bars (e.g. 4 “#3”) or force the user to provide the total area of steel (As)? Should the user pass in 3, “3”, or “#3”? Should we calculate depth inside the function or let the user specify it explicitly (to accommodate cases where user wants two layers or more of rebar)? Perhaps we can calculate the centroid of tension steel automatically? Should we make the function really large and complex but easy for user to use? Or make the function short and simple but require more pre-processing and understanding from the user? These are all questions that programmers engage with on a daily basis. Each individual decision doesn’t matter in the grand scheme of things, but taken together, they separate good software design that brings joy from frustrating software that you learn to tolerate. ↩