Win32 Structured Storage: IStorage Transactions, Commit, and Revert
Use IStorage transactions safely: distinguish stream flushes from storage commits, avoid destructive creates, and recover from stale writers.
Windows Structured Storage is a COM API for organizing a document as named storages and streams. The COM compound-file implementation represents that hierarchy in a single file, but the abstraction is not a database and does not understand the application format stored in each stream. IStorage owns the hierarchy and its transaction boundaries; IStream reads and writes bytes. Correct persistence therefore depends on choosing compatible modes, committing each transactional level in order, and treating every returned HRESULT as part of the save protocol.
This guide covers the Win32 compound-file implementation selected with STGFMT_DOCFILE, using IStorage, IStream, and the StgCreateStorageEx API. Microsoft Learn currently lists Windows 2000 as the minimum supported client for these APIs. The sample uses longstanding desktop Win32 interfaces rather than a newer, version-specific feature. Documentation and API statements below were checked against the official Microsoft Learn references on 2026-10-13; the example was syntax-checked with the installed x86_64 MinGW-w64 GCC 16.2.0 toolchain, not executed on Windows.
Keep the storage tree separate from the payload format
Think of a compound file as a directory-like tree, not as a flat byte stream. A storage can contain named streams and nested storages. A stream contains bytes, but IStorage and IStream do not parse those bytes as XML, a property list, a media file, or a domain-specific document. Your application owns payload versioning, validation, migration, and compatibility.
The COM compound-file implementation has naming constraints that shape schema design. In this implementation, stream and storage names share one namespace inside a parent, and an element name may contain at most 31 characters, excluding its terminator. These are compound-file implementation limits, not universal rules for every possible Structured Storage provider. Choose short, stable names and version the data inside a stream instead of encoding an unbounded user label into the element name.
Use StgCreateStorageEx for a new root compound file and StgOpenStorageEx for an existing one. Do not treat a create call as an open-or-update operation. If the destination already exists, the default creation behavior is fail-if-there; adding STGM_CREATE changes the contract by replacing the existing file or element. That flag is useful only when replacement is intentional and recoverable. For a save workflow, create a new uniquely named staging file, commit and validate it, then publish it through a separate operation whose collision, backup, and rollback rules are explicit.
Pick one access, sharing, and transaction mode per level
STGM flags are grouped. Select only one flag from each group, including access, sharing, creation, and transaction mode. A common single-writer root mode is STGM_READWRITE | STGM_SHARE_EXCLUSIVE | STGM_TRANSACTED. Exclusive sharing prevents another opener from accessing that root while it is held and avoids the multiple-writer problem for this example. It is not a general multi-process coordination protocol for every storage provider.
STGM_TRANSACTED buffers changes at the storage level. Releasing the final reference before committing loses that level’s pending changes; IStorage::Revert discards them back to the last committed state. Omitting the flag selects direct mode by default, where changes are made directly rather than collected for a later storage commit. Do not assume a flag on the root automatically turns every nested element into an independent transaction. Choose and document the mode for each IStorage opened or created in the tree.
There is an important compound-file exception: its IStream implementation does not support transacted streams. A stream is direct even when its containing storage is transacted. IStream::Commit can flush stream buffers into the next-level storage, but it is not a stream rollback boundary. The parent storage remains the place where uncommitted changes can still be reverted. Never advertise IStream::Revert as a reliable undo feature for a compound-file stream.
Commit from the leaves toward the root
A storage commit publishes that storage’s known changes to its parent. It does not automatically commit open nested storages. For a nested transaction tree, commit the changed child storage first, then each parent in order, and finally the root. The root commit is the operation that publishes the transaction to the backing file. Check each HRESULT; a successful child commit only advances data to its parent and does not prove that the file was committed.
This sequence also clarifies where to place validation. Write the new stream, flush it, close the stream handle, commit the containing storage, and commit the root. If validation must inspect the serialized representation, perform that validation against the staged file after a successful root commit and before replacing the live path. A hash or successful Commit proves neither that your payload is semantically valid nor that the intended application can reopen it.
The helper below assumes its caller invokes it on a thread where COM has already been initialized with CoInitializeEx or CoInitialize, using that thread’s established apartment. The helper does not initialize or uninitialize COM; the caller that successfully initializes the thread is responsible for one matching CoUninitialize call for each successful initialization before the thread exits.
#include <windows.h>
#include <objbase.h>
template <class T>
class ComOwner {
public:
ComOwner() = default;
ComOwner(const ComOwner&) = delete;
ComOwner& operator=(const ComOwner&) = delete;
~ComOwner() { reset(); }
T** put() noexcept { reset(); return &value_; }
T* get() const noexcept { return value_; }
T* operator->() const noexcept { return value_; }
void reset() noexcept {
if (value_) {
value_->Release();
value_ = nullptr;
}
}
private:
T* value_ = nullptr;
};
HRESULT CreateNewCompoundFile(const wchar_t* uniqueStagingPath) {
if (!uniqueStagingPath || !*uniqueStagingPath)
return E_INVALIDARG;
ComOwner<IStorage> root;
const DWORD rootMode = STGM_READWRITE | STGM_SHARE_EXCLUSIVE |
STGM_TRANSACTED;
HRESULT hr = StgCreateStorageEx(
uniqueStagingPath, rootMode, STGFMT_DOCFILE, 0,
nullptr, nullptr, IID_IStorage,
reinterpret_cast<void**>(root.put()));
if (FAILED(hr))
return hr; // No STGM_CREATE: an existing path must not be replaced.
ComOwner<IStorage> record;
const DWORD childMode = STGM_READWRITE | STGM_SHARE_EXCLUSIVE |
STGM_TRANSACTED;
hr = root->CreateStorage(L"Record", childMode, 0, 0, record.put());
if (FAILED(hr))
return hr;
ComOwner<IStream> payload;
const DWORD streamMode = STGM_READWRITE | STGM_SHARE_EXCLUSIVE;
hr = record->CreateStream(L"Payload", streamMode, 0, 0, payload.put());
if (FAILED(hr))
return hr;
static constexpr char bytes[] = "format-version=1\nstatus=ready\n";
constexpr ULONG byteCount = static_cast<ULONG>(sizeof(bytes) - 1);
ULONG offset = 0;
while (offset < byteCount) {
ULONG written = 0;
hr = payload->Write(bytes + offset, byteCount - offset, &written);
if (hr != S_OK) {
if (SUCCEEDED(hr))
hr = E_UNEXPECTED; // Reject an undocumented success status such as S_FALSE.
break;
}
if (written == 0 || written > byteCount - offset) {
hr = E_FAIL; // No progress or an impossible byte count.
break;
}
offset += written;
}
if (SUCCEEDED(hr))
hr = payload->Commit(STGC_DEFAULT); // Flush only, not a stream transaction.
payload.reset();
if (FAILED(hr))
return hr;
hr = record->Commit(STGC_DEFAULT);
record.reset();
if (FAILED(hr))
return hr;
return root->Commit(STGC_DEFAULT);
}
The example creates a new file and fails rather than replacing an existing path. It gives each storage level an explicit transacted mode, leaves the compound-file stream in direct mode, loops over reported partial writes only while Write returns the documented S_OK, rejects zero progress or an impossible byte count, and commits only after the complete buffer is written. An unexpected success status such as S_FALSE is converted to E_UNEXPECTED; this prevents SUCCEEDED(hr) from accidentally authorizing a commit. Its HRESULT is the save result; the caller should log it and apply a policy for a failed staging file. In production, pass only a private, unique staging path that the process owns, release all interfaces before any cleanup, and never delete or overwrite a caller-supplied path merely because a save failed. The example deliberately does not implement atomic publication or payload validation.
Handle reverts and concurrent writers as lifecycle events
IStorage::Revert rolls a transacted storage back to its last commit, including changes committed into it by nested elements. After a storage is reverted, existing child IStorage and IStream interfaces opened from that storage are invalid. Their only permitted operation is IUnknown::Release; close them and reacquire fresh interfaces before continuing. This is why rollback is easier to reason about when the save operation owns a short-lived tree of COM references rather than sharing element pointers across unrelated tasks.
If multiple writers are allowed, a successful open does not mean that the version you edited is still current at commit time. Use STGC_ONLYIFCURRENT when the storage can be concurrently edited. A stale writer receives STG_E_NOTCURRENT; stop and reload, merge at the application-data level, or ask the user to resolve the conflict. Do not retry with STGC_DEFAULT automatically, because that overrides the conflict protection and can discard another writer’s update. The default STGC behavior is more robust than STGC_OVERWRITE; the latter can fail after destroying old data and before the new version is fully committed. Avoid it for normal saves.
Transacted mode also has cost. Snapshot and scratch behavior depends on the root sharing mode and other openers, and opening with permissive sharing can require copying the existing storage. Measure realistic files and access patterns before adding STGM_NOSCRATCH, STGM_NOSNAPSHOT, or other performance flags. Those options change storage behavior and should not be cargo-culted from a benchmark with a different reader/writer model.
A production save checklist
Before adopting Structured Storage, decide whether the compatibility requirement is specifically a COM compound file or a different container format. Document the hierarchy, name limits, payload versions, ownership of each interface, and exactly which levels are transacted. On save, fail safely on name collisions, check every HRESULT, write and validate a staged artifact, commit the tree from child to root, and publish only after the root commit succeeds. Keep a recoverable prior version if replacement is a separate operation.
Test at least these cases on supported Windows versions: an existing destination, an existing child name, access denial, low disk space during child and root commits, a short or failed stream write, Revert with open children, two writers editing the same opened version, and reopening the committed file after process restart. Verify application-level payload invariants, not merely that StgOpenStorageEx returns S_OK. A Windows cross-compile verifies declarations and type usage only; it cannot prove the operating system’s transaction, sharing, filesystem, or crash-recovery behavior.
Related:
- COM Apartments on Windows: STA, MTA, Marshaling, and Message Pumps
- Windows CopyFile2: Progress, Cancellation, and Safe Publication
Sources:
- Microsoft Learn: Structured Storage Interfaces
- Microsoft Learn: StgCreateStorageEx
- Microsoft Learn: StgOpenStorageEx
- Microsoft Learn: STGM Constants
- Microsoft Learn: IStorage::CreateStorage
- Microsoft Learn: IStorage::CreateStream
- Microsoft Learn: IStorage::Commit
- Microsoft Learn: IStorage::Revert
- Microsoft Learn: IStream::Commit
- Microsoft Learn: ISequentialStream::Write
- Microsoft Learn: STGC enumeration
- Microsoft Learn: The COM Library