The Query API reads and writes data in any LabKey schema. Every method targets a table or query by its schema
name and query name — the same pair shown in the server UI under Admin → Go To Module → Query, and in the
URL of any data grid (e.g. .../query-executeQuery.view?schemaName=lists&query.queryName=Demographics).
The API is modeled after the LabKey JavaScript client API of the same name, so the method names and payloads correspond closely to their JavaScript counterparts.
The classes below are imported from labkey.query:
from labkey.query import AuditBehavior, InsertOption, Pagination, QueryFilterRepresents a single filter clause. Pass a list of them as the filter_array argument to select_rows.
QueryFilter(column, value, filter_type=QueryFilter.Types.EQUAL)| Argument | Type | Description |
|---|---|---|
column |
str |
Name of the column to filter on. |
value |
str |
The value to compare against. Ignored by the "no data value" types below. |
filter_type |
str |
One of QueryFilter.Types. Defaults to EQUAL. |
Multiple filters may target the same column; each is applied.
QueryFilter.Types enumerates the available operators:
| Category | Types |
|---|---|
| Equality | EQUAL, NEQ / NOT_EQUAL, NEQ_OR_NULL / NOT_EQUAL_OR_MISSING, DATE_EQUAL, DATE_NOT_EQUAL |
| Comparison | GT / GREATER_THAN, GTE / GREATER_THAN_OR_EQUAL, LT / LESS_THAN, LTE / LESS_THAN_OR_EQUAL, and the DATE_ prefixed equivalents |
| Ranges and sets | BETWEEN, NOT_BETWEEN, IN / EQUALS_ONE_OF, NOT_IN / EQUALS_NONE_OF, MEMBER_OF |
| Strings | STARTS_WITH, DOES_NOT_START_WITH, CONTAINS, DOES_NOT_CONTAIN, CONTAINS_ONE_OF, CONTAINS_NONE_OF |
| Arrays | ARRAY_CONTAINS_ALL, ARRAY_CONTAINS_ANY, ARRAY_CONTAINS_NONE, ARRAY_CONTAINS_EXACT, ARRAY_CONTAINS_NOT_EXACT, ARRAY_ISEMPTY, ARRAY_ISNOTEMPTY |
| No data value | HAS_ANY_VALUE, IS_BLANK, IS_NOT_BLANK, HAS_MISSING_VALUE, DOES_NOT_HAVE_MISSING_VALUE |
| Search, ontology, lineage | Q (table-wide search), ONTOLOGY_IN_SUBTREE, ONTOLOGY_NOT_IN_SUBTREE, EXP_CHILD_OF, EXP_PARENT_OF, EXP_LINEAGE_OF |
Multi-value types are not consistent in how they delimit values — this is a historical artifact of the underlying
API. BETWEEN and NOT_BETWEEN take a comma separated pair ("50, 70"); IN and NOT_IN take a semicolon
separated list ("Germany;Uganda").
Paging styles for the show_rows argument of select_rows: PAGINATED, SELECTED, UNSELECTED, ALL, NONE.
Overrides the audit detail level of a write operation: DETAILED, SUMMARY, NONE. DETAILED records the
values before and after the change, SUMMARY records only that a change occurred. When omitted, the table's
configured behavior applies.
How import_rows applies the rows it reads. Not every table supports every option — the server rejects an
unsupported combination with an error.
| Value | Effect |
|---|---|
IMPORT |
Bulk insert, creating a new row for every row of data. The default. |
INSERT |
Insert one row at a time, reselecting each inserted row. |
MERGE |
Insert new rows; for rows that already exist, update only the columns present in the data. |
REPLACE |
Like MERGE, but also nulls the columns of an existing row that the data omits. |
UPSERT |
Like MERGE, but reselects the affected rows. |
UPDATE |
Update existing rows only, failing if a row does not exist. |
IMPORT_IDENTITY |
Bulk insert that preserves the primary key values supplied in the data. |
A TypedDict describing one operation in a save_rows request. Keys use Python style names and are converted to
the server's JSON names for you.
| Key | Type | Required | Description |
|---|---|---|---|
command |
"insert" / "update" / "delete" |
Yes | The operation to perform. |
schema_name |
str |
Yes | Schema of the target table. |
query_name |
str |
Yes | Target table name. |
rows |
List[dict] |
Yes | The rows to insert, update, or delete. |
container_path |
str |
No | Overrides the container for this command only. |
audit_behavior |
AuditBehavior |
No | Audit detail level for this command. |
audit_user_comment |
str |
No | Comment attached to detailed audit records. |
extra_context |
dict |
No | Passed to the transformation/validation script environment. |
skip_reselect_rows |
bool |
No | Skip returning the full detail of the affected rows. |
All methods are available on the query member of an APIWrapper instance.
| Method | Description |
|---|---|
select_rows(schema_name, query_name, ...) |
Query a table or query and return the result set. |
execute_sql(schema_name, sql, ...) |
Execute LabKey SQL against a schema. |
insert_rows(schema_name, query_name, rows, ...) |
Insert rows into a table. |
update_rows(schema_name, query_name, rows, ...) |
Update existing rows. Each row must carry its primary key. |
delete_rows(schema_name, query_name, rows, ...) |
Delete rows. Each row need only carry its primary key. |
move_rows(target_container_path, schema_name, query_name, rows, ...) |
Move rows to another container. |
truncate_table(schema_name, query_name, ...) |
Delete every row in a table. |
import_rows(schema_name, query_name, data_file, ...) |
Bulk insert or merge rows from a file, inline text, or a file already on the server. |
save_rows(commands, ...) |
Perform inserts, updates, and deletes across several tables in one request. |
get_queries(schema_name, ...) |
List the queries available in a schema. |
| Argument | Default | Description |
|---|---|---|
container_path |
None |
Overrides the container path configured on the APIWrapper for this request. |
transacted |
True |
Whether the writes are applied in a single transaction, so that they all succeed or all fail. |
audit_behavior |
None |
See AuditBehavior. |
audit_user_comment |
None |
Comment attached to certain detailed audit log records. |
timeout |
300 |
Request timeout in seconds. Exceeding it raises requests.exceptions.Timeout. |
container_path, transacted, audit_behavior, and audit_user_comment apply to the write methods
(insert_rows, update_rows, delete_rows, move_rows); read methods accept container_path and timeout.
import_rows accepts container_path, audit_behavior, audit_user_comment, and timeout, but not transacted
— an import is always transacted.
select_rows
| Argument | Default | Description |
|---|---|---|
view_name |
None |
Name of an existing custom view to apply. |
filter_array |
None |
List of QueryFilter objects. |
columns |
None |
Comma separated list of columns to retrieve. Lookups may be traversed with /, e.g. "Sample/Name". |
max_rows |
-1 |
Maximum rows to return. -1 means unlimited. |
offset |
None |
Number of rows to skip. |
sort |
None |
Comma separated column list. Prefix a column with - to sort descending. |
show_rows |
None |
A Pagination value. |
include_total_count |
None |
Include the total row count in the response, independent of paging. |
include_details_column |
None |
Include a Details link column in the results. |
include_update_column |
None |
Include an Update link column in the results. |
container_filter |
None |
Broadens the query beyond the target container. See the link at the top of this page. |
parameters |
None |
Values for a parameterized query, as a dict. |
ignore_filter |
None |
When True, filters saved on the chosen view are ignored. |
required_version |
None |
Response format version. |
execute_sql accepts container_filter, max_rows, offset, sort, parameters, required_version, and:
| Argument | Default | Description |
|---|---|---|
save_in_session |
None |
Save the query in the session. The response's queryName can then be passed to select_rows as query_name. |
waf_encode_sql |
True |
Encode the SQL so that web application firewalls do not reject the request. Rarely needs to change. |
import_rows
The rows come from one of four sources. The server uses the first one supplied in this order — text, path,
module_resource, data_file — and ignores the others, so pass exactly one.
| Argument | Default | Description |
|---|---|---|
data_file |
None |
An open file handle, uploaded as multipart form data. Its column headers must match the LabKey column names. |
text |
None |
The rows as inline delimited text, including the header row. |
path |
None |
Path of a file already on the server, resolved against the WebDAV root, e.g. "_webdav/MyProject/@files/data.tsv". The current user must be able to read it. |
module_resource |
None |
Path of a TSV resource inside a module, relative to the module root. A value with no / is resolved under the module's schemas/dbscripts directory. |
module |
None |
Name of the module to resolve module_resource against. Only used with module_resource; defaults to the module owning the target table's schema. |
The remaining arguments control how the rows are applied:
| Argument | Default | Description |
|---|---|---|
insert_option |
"IMPORT" |
An InsertOption value. "IMPORT" creates a new row for every row of data; "MERGE" updates rows that already exist and inserts the rest. When merging you only need to supply the columns you want to change. |
audit_behavior |
None |
"SUMMARY" or "DETAILED". Defaults to the setting on the LabKey query. |
audit_user_comment |
None |
Comment attached to certain detailed audit log records. |
audit_details |
None |
A dict of extra detail to record on the import's transaction audit event, serialized to JSON for you. Keys are matched case insensitively against the server's transaction detail names ("Product", "EditMethod", "RequestSource", …); unrecognized keys are ignored. |
import_lookup_by_alternate_key |
False |
Resolve lookup targets by value rather than by primary key. Only works for lookups configured with unique column information. |
import_identity |
False |
Insert the primary key values present in the data instead of letting the server assign them. Requires an administrator, and only applies to tables with an auto incrementing primary key. |
format |
"tsv" |
Delimiter of text, either "csv" or "tsv". Ignored by the other sources, whose format comes from the file itself. |
save_to_pipeline |
False |
Copy the uploaded file into a QueryImportFiles directory under the container's pipeline root rather than discarding it once the import completes. Requires a pipeline root. |
use_async |
False |
Run the import in a background pipeline job, which also saves the file to the pipeline root. The response holds jobId instead of a row count, and not every table supports it. |
import_url |
None |
Full URL of an alternate import action to post to, replacing the default query-import.api. Use it to reach an import action on another controller that accepts the same parameters. |
save_rows
| Argument | Default | Description |
|---|---|---|
commands |
required | A list of Command dicts. |
api_version |
None |
When 13.2 or higher, a request that fails validation is returned as a successful response — check errorCount and committed — instead of raising. |
transacted |
None |
Whether all commands are applied in one transaction. Defaults to True on the server. |
validate_only |
None |
Run every command but commit nothing. Useful for incremental validation of a UI form. |
extra_context |
None |
Passed to the transformation/validation script environment for all commands. |
get_queries accepts include_columns, include_system_queries, include_title, include_user_queries,
include_view_data_url (all default True), and query_detail_columns (default False, and only meaningful when
include_columns is True).
All methods return the decoded JSON response as a dict.
select_rows and execute_sql return:
| Key | Description |
|---|---|
rows |
A list of dicts, one per row, keyed by column name. |
rowCount |
Number of rows. Reflects the total row count when include_total_count is True. |
columnModel |
Metadata for each returned column, including its header. |
metaData |
Result set metadata, including id — the name of the primary key column. |
schemaName |
The queried schema. execute_sql reports queryName as "sql". |
queryName |
The queried table, or the session query name when save_in_session is used. |
insert_rows, update_rows, delete_rows, and move_rows return rowsAffected and a rows list holding the
affected rows as they exist after the operation. truncate_table returns deletedRows. import_rows returns
success and rowCount, or success: False with errorCount and errors — it reports validation failures in the
response rather than raising. With use_async=True it returns success and jobId instead, since the rows are
loaded after the response is sent. save_rows returns committed, errorCount, and result, a list parallel to
commands where each entry has its own rowsAffected and rows. get_queries returns schemaName and queries.
Note that keys in write responses are lower cased by the server, so a RowId column is read back as rowid.
Errors are raised as subclasses of labkey.exceptions.RequestError, which extends
requests.exceptions.RequestException. All of them expose a message attribute containing the HTTP status code
and the server's error text.
| Exception | Raised when |
|---|---|
RequestError |
Base class. Catch this to handle any server error. |
QueryNotFoundError |
The schema or query does not exist. |
RequestAuthorizationError |
The user is not authorized for the request. |
ServerNotFoundError |
The server resource was not found — usually a bad context path or container path. |
UnexpectedRedirectError |
The server redirected the request, e.g. from http to https. |
ServerContextError |
The request could not be completed — connection, SSL, or URL parsing failure — or the server rejected the operation with an error message. |
Every example below uses an APIWrapper instance to make its requests. See api_wrapper.md for the
full set of APIWrapper arguments, including how to configure the container path, context path, SSL, and
authentication.
from labkey.api_wrapper import APIWrapper
from labkey.query import Pagination
labkey_server = "www.example.com"
container_path = "Tutorials/HIV Study" # Full project/folder container path
context_path = "labkey"
api = APIWrapper(labkey_server, container_path, context_path)
schema = "lists"
table = "Demographics"
###################
# Basic select_rows
###################
result = api.query.select_rows(schema, table)
if result is not None:
print(result["rows"][0])
print("select_rows: There are " + str(result["rowCount"]) + " rows.")
else:
print("select_rows: Failed to load results from " + schema + "." + table)
###################
# Page the results and read the response metadata
###################
result = api.query.select_rows(
schema,
table,
max_rows=5,
offset=10,
include_total_count=True,
include_details_column=True,
include_update_column=True,
)
print("select_rows: There are " + str(len(result["rows"])) + " rows.")
print("select_rows: There are " + str(result["rowCount"]) + " total rows.")
columns = [column["header"] for column in result["columnModel"]]
print("select_rows: Included columns: " + ", ".join(columns))
key_column = result["metaData"]["id"]
print("select_rows: The first row key is: " + str(result["rows"][0][key_column]))
###################
# Retrieve every row, regardless of the default page size
###################
result = api.query.select_rows(schema, table, show_rows=Pagination.ALL, include_total_count=True)
###################
# Select specific columns, sorted ascending by one and descending by another
###################
result = api.query.select_rows(
schema,
table,
columns="Group Assignment, Participant ID",
sort="Group Assignment, -Participant ID", # use '-' to sort descending
)
for row in result["rows"]:
print("\t" + str(row["Group Assignment"]) + ", " + str(row["Participant ID"]))from labkey.api_wrapper import APIWrapper
from labkey.query import QueryFilter
api = APIWrapper("www.example.com", "Tutorials/HIV Study", "labkey")
filters = [
QueryFilter("Group Assignment", "Group 2: HIV-1 Negative"),
QueryFilter("Height (inches)", "50, 70", QueryFilter.Types.BETWEEN),
QueryFilter("Country", "Germany;Uganda", QueryFilter.Types.IN),
]
result = api.query.select_rows("lists", "Demographics", filter_array=filters)
print("select_rows: There are " + str(result["rowCount"]) + " rows.")from labkey.api_wrapper import APIWrapper
api = APIWrapper("www.example.com", "Tutorials/HIV Study", "labkey")
schema = "lists"
sql = "SELECT * FROM lists.Demographics"
result = api.query.execute_sql(schema, sql)
print("execute_sql: There are " + str(result["rowCount"]) + " rows.")
###################
# Paging and sorting are applied the same way as in select_rows
###################
result = api.query.execute_sql(schema, sql, max_rows=5, offset=10, sort="Country")
###################
# Save the results in the session, then query them by name
###################
result = api.query.execute_sql(schema, sql, save_in_session=True)
session_query = result["queryName"]
print("execute_sql: query saved as [ " + session_query + " ]")
result = api.query.select_rows(schema, session_query)from labkey.api_wrapper import APIWrapper
from labkey.query import AuditBehavior
api = APIWrapper("www.example.com", "Tutorials/HIV Study", "labkey")
schema = "lists"
table = "Demographics"
###################
# Insert. The response holds the inserted rows, including their new keys.
###################
result = api.query.insert_rows(schema, table, [{"Country": "Antarctica"}])
new_key = result["rows"][0]["Key"]
print("insert_rows: new rowId [ " + str(new_key) + " ]")
###################
# Update. Supply the primary key plus only the columns being changed.
###################
result = api.query.update_rows(
schema,
table,
[{"Key": new_key, "Country": "Pangea"}],
audit_behavior=AuditBehavior.DETAILED,
audit_user_comment="Corrected the country of origin.",
)
print("update_rows: updated value [ " + result["rows"][0]["Country"] + " ]")
###################
# Delete. The primary key is all that is required.
###################
result = api.query.delete_rows(schema, table, [{"Key": new_key}])
print("delete_rows: deleted rowId [ " + str(result["rows"][0]["Key"]) + " ]")
###################
# Delete every row in the table
###################
result = api.query.truncate_table(schema, table)
print("truncate_table: [ " + str(result["deletedRows"]) + " ] rows deleted")save_rows applies any mix of inserts, updates, and deletes in a single transaction, across as many tables as
needed. Values in the MaterialInputs/<SampleType> and DataInputs/<DataClass> form register lineage on the
inserted rows.
from labkey.api_wrapper import APIWrapper
api = APIWrapper("www.example.com", "Biologics")
commands = [
{
"command": "insert",
"schema_name": "samples",
"query_name": "Blood",
"rows": [
{"name": "BL-3", "MaterialInputs/Tissues": "T-1"},
{"name": "BL-4", "MaterialInputs/Blood": "BL-2"},
],
},
{
"command": "update",
"schema_name": "samples",
"query_name": "Tissues",
"rows": [{"rowId": 1234, "ReceivedDate": "2025-07-07 12:34:56"}],
},
{
"command": "delete",
"schema_name": "samples",
"query_name": "Blood",
"rows": [{"rowId": 5678}],
},
]
result = api.query.save_rows(commands=commands)
print("save_rows: committed [ " + str(result["committed"]) + " ]")
for index, command_result in enumerate(result["result"]):
print("command " + str(index) + ": " + str(command_result["rowsAffected"]) + " rows affected")By default a command that fails validation raises a ServerContextError. Pass api_version=13.2 to receive the
failure as a normal response instead, which is useful when you want to report every error rather than just the
first one.
result = api.query.save_rows(api_version=13.2, commands=commands)
if not result["committed"]:
print("save_rows: " + str(result["errorCount"]) + " error(s), nothing was committed")
for command_result in result["result"]:
if "errors" in command_result:
print(command_result["errors"]["exception"])import_rows is the efficient way to load a large number of rows. Unlike the other write methods it reports
validation problems in its response rather than raising.
from labkey.api_wrapper import APIWrapper
api = APIWrapper("www.example.com", "Tutorials/HIV Study", "labkey")
with open("demographics.csv", "r") as data_file:
result = api.query.import_rows("lists", "Demographics", data_file=data_file)
if result["success"]:
print("import_rows: imported " + str(result["rowCount"]) + " rows")
else:
print("import_rows: " + str(result["errorCount"]) + " error(s)")
for error in result["errors"]:
print(error["exception"])To update existing rows from the same file, import with the "MERGE" option. If the file identifies lookup values
by name rather than by row id — a parent column holding parent_one instead of 1 — set
import_lookup_by_alternate_key so the server resolves them.
from labkey.query import InsertOption
with open("child_data.csv", "r") as data_file:
result = api.query.import_rows(
"lists",
"child_list",
data_file=data_file,
insert_option=InsertOption.MERGE,
import_lookup_by_alternate_key=True,
)A small set of rows can be passed inline as text instead of a file. format selects the delimiter; it applies only
to text.
result = api.query.import_rows(
"lists",
"Demographics",
text="Participant ID,Country\n2001,Antarctica\n2002,Pangea\n",
format="csv",
)A file that is already on the server does not have to be uploaded at all. Pass its WebDAV path — the same path the
file browser shows — and the server reads it in place.
result = api.query.import_rows(
"lists",
"Demographics",
path="_webdav/Tutorials/HIV Study/@files/demographics.tsv",
)For an import large enough that the request would time out, use use_async to hand it to a pipeline job. The
response carries a jobId rather than a row count, and progress and errors show up in the container's pipeline
status. save_to_pipeline keeps the uploaded file under the pipeline root without moving the import itself into the
background. Both require a pipeline root to be configured for the container.
with open("large_demographics.tsv", "r") as data_file:
result = api.query.import_rows(
"lists",
"Demographics",
data_file=data_file,
use_async=True,
audit_user_comment="Nightly load of the demographics extract.",
audit_details={"Product": "python", "RequestSource": "nightly_etl.py"},
)
print("import_rows: queued pipeline job [ " + str(result["jobId"]) + " ]")move_rows takes the destination container as its first argument. The source container is the one configured on
the APIWrapper, or whatever is passed as container_path.
from labkey.api_wrapper import APIWrapper
api = APIWrapper("www.example.com", "Biologics")
result = api.query.move_rows(
"Biologics/Archive",
"samples",
"Blood",
[{"rowId": 1234}, {"rowId": 5678}],
audit_user_comment="Archiving samples from the completed study.",
)
print("move_rows: moved " + str(result["rowsAffected"]) + " rows")from labkey.api_wrapper import APIWrapper
api = APIWrapper("www.example.com", "Tutorials/HIV Study", "labkey")
result = api.query.get_queries("core")
for query in result["queries"]:
print(query["name"] + " — " + query["title"])
###################
# Limit the results to queries defined by a module
###################
result = api.query.get_queries("core", include_system_queries=False, include_user_queries=False)from labkey.api_wrapper import APIWrapper
from labkey.exceptions import QueryNotFoundError, RequestError, ServerContextError
from requests.exceptions import Timeout
api = APIWrapper("www.example.com", "Tutorials/HIV Study", "labkey")
# A missing schema or query
try:
api.query.select_rows("lists", "NoSuchTable")
except QueryNotFoundError as e:
print("Query not found: " + e.message)
# An operation the server rejects
try:
api.query.delete_rows("core", "datastates", [{"rowid": 1}])
except ServerContextError as e:
print("Server rejected the request: " + e.message)
# Any server error
try:
api.query.select_rows("badSchema", "Demographics")
except RequestError as e:
print("Request failed: " + e.message)
# A request that takes too long
try:
api.query.execute_sql("lists", "SELECT * FROM lists.Demographics", timeout=0.001)
except Timeout:
print("Request timed out")This example is longer than the others and combines several of the methods above. It walks through the full life cycle of a QC state definition in a study folder: creating states, renaming one, assigning one to a dataset row, and cleaning up. Along the way it shows how the server's constraints surface through the API.
QC state definitions live in core.DataStates. The related core.QCState table is a read-only view over the same
rows that excludes LIMS sample statuses (rows with a non-null StateType), so writes must target
core.DataStates.
from labkey.api_wrapper import APIWrapper
from labkey.exceptions import ServerContextError
from labkey.query import AuditBehavior, QueryFilter
api = APIWrapper("www.example.com", "Tutorials/HIV Study", "labkey")
###################
# Create two QC state definitions. publicData controls whether data in this
# state is visible to users who lack permission to view unapproved data.
###################
qc_states = [
{
"label": "needs verification",
"description": "that can not be right",
"publicData": False,
},
{"label": "approved", "publicData": True},
]
result = api.query.insert_rows("core", "DataStates", qc_states)
print("Created " + str(result["rowsAffected"]) + " QC states")
# Note the lower cased keys in write responses
needs_verification_id = result["rows"][0]["rowid"]
approved_id = result["rows"][1]["rowid"]
###################
# Labels are unique per container, so re-creating one is an error
###################
try:
api.query.insert_rows("core", "DataStates", [{"label": "approved", "publicData": True}])
except ServerContextError as e:
print("Duplicate label rejected: " + e.message)
###################
# Update a definition. Only the primary key and the changed columns are needed.
###################
result = api.query.update_rows(
"core",
"DataStates",
[{"rowid": needs_verification_id, "description": "for sure that is not right"}],
audit_behavior=AuditBehavior.DETAILED,
audit_user_comment="Clarified the description for reviewers.",
)
print("Updated description: " + result["rows"][0]["description"])
###################
# Assign the state to a dataset row. QCState is a lookup to core.DataStates,
# so it takes the state's rowId.
###################
result = api.query.insert_rows(
"study",
"Lab Results",
[
{
"ParticipantId": "2",
"SequenceNum": "345",
"Value": 4,
"QCState": needs_verification_id,
}
],
)
dataset_row_lsid = result["rows"][0]["lsid"]
###################
# List the states that are defined, and which are public
###################
result = api.query.select_rows(
"core",
"DataStates",
columns="RowId, Label, Description, PublicData",
filter_array=[QueryFilter("StateType", "", QueryFilter.Types.IS_BLANK)],
sort="Label",
)
for row in result["rows"]:
print(row["Label"] + " (public: " + str(row["PublicData"]) + ")")
###################
# A state that is in use cannot be deleted
###################
try:
api.query.delete_rows("core", "DataStates", [{"rowid": needs_verification_id}])
except ServerContextError as e:
# 400: State 'needs verification' cannot be deleted as it is currently in use.
print("Delete blocked: " + e.message)
###################
# Stop using the state, then clean up both definitions. Dataset rows are keyed
# by LSID rather than by an integer row id.
###################
api.query.delete_rows("study", "Lab Results", [{"lsid": dataset_row_lsid}])
api.query.delete_rows(
"core",
"DataStates",
[{"rowid": needs_verification_id}, {"rowid": approved_id}],
)The same sequence can be expressed as a single save_rows request when the operations do not depend on ids
returned by earlier steps — for example deleting the dataset row and its QC state together, so that neither is
applied if the other fails.
result = api.query.save_rows(
commands=[
{
"command": "delete",
"schema_name": "study",
"query_name": "Lab Results",
"rows": [{"lsid": dataset_row_lsid}],
},
{
"command": "delete",
"schema_name": "core",
"query_name": "DataStates",
"rows": [{"rowid": needs_verification_id}],
},
]
)
print("save_rows: committed [ " + str(result["committed"]) + " ]")