Age Open Transactions with sys.dm_tran_active_transactions
A transaction that never commits keeps every log record written after it pinned in place, no matter how much work piles up behind it. sys.dm_tran_active_transactions lists every transaction currently open on the instance, but on its own it only shows a transaction ID and a start time — pairing it with sys.dm_tran_session_transactions and sys.dm_exec_sessions turns that anonymous row into a specific session, login, and elapsed duration worth investigating.
Purpose and Overview
The transaction log can only truncate up to the oldest point still needed for recovery, and an open transaction — no matter how small the actual data change behind it — holds that point fixed wherever it began. A transaction log backup running on schedule and a CHECKPOINT completing normally free nothing if a session opened a transaction ten minutes ago and hasn't committed or rolled back since. The log keeps growing, autogrow events start firing, and the eventual full-log error traces back to a single session that most routine monitoring never surfaces directly.
sys.dm_tran_active_transactions is the server-scoped DMV that lists every transaction currently active on the instance, but by itself it's anonymous — it exposes a transaction_id, a transaction_begin_time, and a transaction_type, with no session, login, or application attached. Joining it to sys.dm_tran_session_transactions supplies the missing session_id, and a further join to sys.dm_exec_sessions resolves that session_id into a login name, host name, and client application. The three-way join is what turns "there's an open transaction somewhere" into "this login, from this application, has had a transaction open for eleven minutes."
The script below produces two views of that same join: a detail query that ages every currently open read/write and system transaction and flags the ones past a 300-second threshold, and a summary rollup that groups by the session's current database to show, at a glance, which database has the oldest open read/write transaction and how many are currently outstanding.
Code Breakdown
The detail query joins all three DMVs, decodes the numeric transaction_type and transaction_state columns into readable text, and computes each transaction's age in seconds.
1SELECT
2 at.transaction_id,
3 at.name AS transaction_name,
4 at.transaction_begin_time,
5 DATEDIFF(SECOND, at.transaction_begin_time, SYSDATETIME()) AS duration_seconds,
6 CASE at.transaction_type
7 WHEN 1 THEN 'Read/write'
8 WHEN 2 THEN 'Read-only'
9 WHEN 3 THEN 'System'
10 WHEN 4 THEN 'Distributed'
11 ELSE 'Unknown'
12 END AS transaction_type_desc,
13 CASE at.transaction_state
14 WHEN 0 THEN 'Not initialized'
15 WHEN 1 THEN 'Initialized, not started'
16 WHEN 2 THEN 'Active'
17 WHEN 3 THEN 'Ended (read-only)'
18 WHEN 4 THEN 'Commit initiated (distributed)'
19 WHEN 5 THEN 'Prepared, awaiting resolution'
20 WHEN 6 THEN 'Committed'
21 WHEN 7 THEN 'Rolling back'
22 WHEN 8 THEN 'Rolled back'
23 ELSE 'Unknown'
24 END AS transaction_state_desc,
25 st.session_id,
26 st.is_user_transaction,
27 st.is_local,
28 es.login_name,
29 es.host_name,
30 es.program_name,
31 es.status AS session_status,
32 es.login_time,
33 DB_NAME(es.database_id) AS database_name
34FROM sys.dm_tran_active_transactions AS at
35INNER JOIN sys.dm_tran_session_transactions AS st
36 ON at.transaction_id = st.transaction_id
37INNER JOIN sys.dm_exec_sessions AS es
38 ON st.session_id = es.session_id
39WHERE at.transaction_type IN (1, 3)
40 AND DATEDIFF(SECOND, at.transaction_begin_time, SYSDATETIME()) > 300
41ORDER BY at.transaction_begin_time ASC;
A companion summary rolls the same join up to the database level, using a declared variable for the age threshold instead of a hard-coded literal:
1DECLARE @ThresholdMinutes INT = 5;
2
3SELECT
4 DB_NAME(es.database_id) AS database_name,
5 COUNT(DISTINCT at.transaction_id) AS open_transaction_count,
6 MIN(at.transaction_begin_time) AS oldest_transaction_start,
7 MAX(DATEDIFF(SECOND, at.transaction_begin_time, SYSDATETIME())) AS longest_duration_seconds
8FROM sys.dm_tran_active_transactions AS at
9INNER JOIN sys.dm_tran_session_transactions AS st
10 ON at.transaction_id = st.transaction_id
11INNER JOIN sys.dm_exec_sessions AS es
12 ON st.session_id = es.session_id
13WHERE at.transaction_type = 1
14 AND DATEDIFF(SECOND, at.transaction_begin_time, SYSDATETIME()) >= @ThresholdMinutes * 60
15GROUP BY DB_NAME(es.database_id)
16ORDER BY longest_duration_seconds DESC;
Joining active_transactions to session_transactions and sessions
sys.dm_tran_active_transactions carries no session_id column at all. Microsoft documents that through bound sessions and distributed transactions a transaction can run under more than one session, so the session link lives in a separate view. sys.dm_tran_session_transactions is the bridge: it holds a row correlating each session to its transaction (one row per session when a transaction spans several), and joining on transaction_id supplies the session_id needed to reach sys.dm_exec_sessions. The final join on session_id resolves login_name, host_name, and program_name — the detail that turns a bare transaction_id into an actionable "which application, which login" answer.
Decoding transaction_type and transaction_state
Both columns arrive from the DMV as plain integers, and neither is self-explanatory without the CASE expression. transaction_type separates read/write transactions (1) from read-only (2), system (3), and distributed (4) transactions. transaction_state tracks where in its lifecycle a transaction currently sits; state 2 (Active) is the normal case for an open transaction still doing work. State 4 is documented as "for distributed transactions only," and state 5 means the transaction is "in a prepared state and waiting resolution," so both usually point at a distributed transaction waiting on resolution rather than a single session simply forgetting to commit. The detail query's type filter excludes type 4, so those states show up only if the filter is widened.
Computing duration_seconds with DATEDIFF
transaction_begin_time is a plain datetime with no built-in age calculation — DATEDIFF(SECOND, at.transaction_begin_time, SYSDATETIME()) does that arithmetic inline. SYSDATETIME() is used here instead of GETDATE() because it has more fractional-seconds precision, though for a second-level threshold the practical difference is negligible; the more important habit is picking one current-time function and using it consistently across both the filter and the display column, so the WHERE clause and the reported duration never disagree.
Filtering to read/write transactions and a duration threshold
WHERE at.transaction_type IN (1, 3) keeps read/write and system transactions and excludes read-only and distributed ones from the detail query, narrowing the result to the transactions most likely to be the log-truncation culprit — a read/write transaction sitting open holds every log record since its start point. Read-only transactions are left out because they write no log records of their own. A distributed transaction that has written data can hold the log just the same, so remove type 4 from the exclusion if linked-server or MSDTC work is in play. Long read-only transactions under row versioning matter for a different reason: they keep the version store from being cleaned up, and that store lives in tempdb unless accelerated database recovery is enabled, in which case it lives in the user database. The DATEDIFF(SECOND, ...) > 300 predicate is the actual "long-open" threshold; 300 seconds is a reasonable starting point on an OLTP system but should be tuned against what a normal batch job or ETL load on the instance is expected to hold open.
Rolling up to a per-database summary
The second query drops the per-transaction detail and groups by DB_NAME(es.database_id) instead, answering a different question: which database has an open-transaction problem right now, and how bad is it. The database is the session's current database (sys.dm_exec_sessions.database_id), so a session that opened a transaction and then switched context, or one working across databases, is attributed to the database it is in now; sys.dm_tran_database_transactions gives the per-database view of where a transaction actually wrote. COUNT(DISTINCT at.transaction_id) shows how many transactions are past the threshold in that database — plain COUNT(*) would count a transaction once per session when bound or distributed sessions, or MARS, produce multiple join rows — and MAX(DATEDIFF(...)) surfaces the single oldest offender's age. The threshold compares seconds against @ThresholdMinutes * 60, because DATEDIFF(MINUTE, ...) counts minute boundaries crossed and can pass a transaction only a little over four minutes old. Declaring @ThresholdMinutes as a variable rather than hard-coding the literal makes the same script reusable across instances with different tolerance levels without editing the WHERE clause each time.
Key Benefits and Use Cases
- Names the responsible session — three DMVs join transaction age directly to
login_name,host_name, andprogram_nameinstead of leaving a baretransaction_idto chase down separately. - Separates transaction types — the
transaction_typefilter keeps read/write and system transactions while excluding read-only and distributed ones, so the result focuses on the transactions most likely to hold the log open. - Age comes from a recorded start time —
transaction_begin_timegives the start time;DATEDIFFturns it into a duration a threshold or alert can act on directly. - Instance-wide in one query — all three DMVs are server-scoped, so a single query surfaces every open transaction across every database without looping database by database.
- Threshold is a variable, not a hard-coded value — the summary query's
@ThresholdMinutesmakes the same script reusable across instances with different tolerance for long-running work. - No setup required — a pure DMV query with no Extended Events session, trace, or configuration change needed before it returns useful data.
Performance Considerations
- Memory-only, no history:
sys.dm_tran_active_transactionsreflects only transactions open at the moment the query runs — once a transaction commits or rolls back it disappears from the view immediately, with nothing retained for later review. - Not the only cause of log growth: replication, database mirroring, Always On availability group synchronization, and Change Data Capture can all hold the log active with zero open transactions present — a clean result from this script rules out the transaction-side cause specifically, not log growth generally.
- Requires VIEW SERVER STATE: querying these DMVs meaningfully needs
VIEW SERVER STATEpermission at the instance level (SQL Server 2022 and later requireVIEW SERVER PERFORMANCE STATEinstead); on Azure SQL Database the scoped equivalent isVIEW DATABASE STATE, and visibility is limited to the current database's sessions there. - Idle-in-transaction looks identical to actively working: a session that opened a transaction and is now sitting idle between statements shows the same
transaction_begin_timeage as one still actively running a long batch — cross-referencees.statusandes.last_request_start_timeto tell the two apart before deciding how urgent a given row is. - Distributed transactions add extra states:
transaction_type = 4rows can sit intransaction_state4 or 5 (the commit process initiated, or prepared and waiting resolution), which is a different problem than a single session forgetting to commit and needs different follow-up. The detail query excludes type 4 by default.
Practical Tips
- Schedule the summary query as a SQL Server Agent job on a short interval — every one to two minutes — and fire a Database Mail alert when
open_transaction_countis greater than zero past the chosen threshold. - When a row's
es.statusshows sleeping alongside an oldtransaction_begin_time, that's the classic forgot-to-commit case — the client application opened a transaction, ran one statement, and never followed up withCOMMITorROLLBACK. - Pull the session's last statement with
DBCC INPUTBUFFER(session_id)or by joiningsys.dm_exec_requeststosys.dm_exec_sql_textbefore deciding whether the transaction is safe to leave alone or needs escalation. - Log detail-query results to a history table so a spike in long-open transactions can be correlated after the fact with log growth events, VLF count increases, or a specific deployment window.
- Treat
KILLas a last resort, not a first response: Microsoft notes that if the session "has much work to undo, the KILL statement can take some time to complete," so a rollback is not instant and the log stays pinned until it finishes.
Conclusion
Detecting a long-open transaction takes a three-DMV join: sys.dm_tran_active_transactions supplies the transaction and its age, sys.dm_tran_session_transactions supplies the owning session, and sys.dm_exec_sessions resolves that session into a login and application worth calling. Run as a scheduled check with an alert threshold, this script turns "the log won't stop growing" from a mystery into a named session and an exact number of minutes — the starting point for deciding whether to wait, investigate, or escalate.
References
- sys.dm_tran_active_transactions (Transact-SQL) — Microsoft Learn — Full column reference including
transaction_type,transaction_state, andtransaction_begin_time. - sys.dm_tran_session_transactions (Transact-SQL) — Microsoft Learn — Column reference for the session-to-transaction correlation view, including
is_user_transactionandis_local. - sys.dm_exec_sessions (Transact-SQL) — Microsoft Learn — Column reference for session-level detail including
login_name,host_name,program_name, andstatus. - Troubleshoot a Full Transaction Log (SQL Server Error 9002) — Microsoft Learn — Explains how open transactions and other factors prevent log truncation and lead to a full-log condition.
- sqlserver-kit on GitHub by Konstantin Taranov — Community collection of links, scripts and tools for SQL Server, including monitoring tools and blocking and transaction-log resources.
Posts in this series
- SQL Server Table Row Count Report: Complete T-SQL Script
- Generate sp_spaceused Commands for All User Tables in T-SQL
- SQL Server Blocking Detection: Find Blocked Sessions
- SQL Server Database Size Report for All Databases
- SQL Server Long-Running Queries: Find Active Sessions
- SQL Server TempDB Usage and Contention Report
- SQL Server Database File Space Usage Report
- SQL Server Get Table and Index Storage Size Report Script
- Age Open Transactions with sys.dm_tran_active_transactions
- Decode PLE and Buffer Cache Hit Ratio via cntr_type Codes
- Spot Low-Memory Conditions with sys.dm_os_process_memory