How to Choose Where a Windows App Stores Local Data — A Decision Table for SQLite / JSON / Registry / Access
· Go Komura · SQLite, Windows, .NET, C#, Data Storage, Registry, Access, Architecture, Decision Table, Technical Consulting
“Is an INI file fine for settings?” “Our history data has grown, so we want to move it into Access.” “How should we split responsibilities between the registry and a settings file?” If you build business Windows applications, choosing where to store data is a decision you will always face. Yet this choice is often made somewhat casually at the start of a project and never revisited — until, years later, problems surface: “our JSON file has grown to tens of megabytes and startup is slow,” “the Access file on the shared folder gets corrupted about once a week,” or “we’re writing directly under Program Files and it no longer works on Windows 11.”
This article organizes local data storage for business Windows apps by separating “where to put it” (choosing a folder) from “what to store it in” (choosing a format or engine). Using the decision-table format we’ve used several times on this blog, we’ll summarize the strengths and pitfalls of SQLite, JSON, the registry, and Access.
1. Bottom line up front
- Choosing where to store data is really two independent decisions: “where to put it” and “what to store it in.” Getting the former wrong leads to permission and multi-user incidents; getting the latter wrong leads to corruption, performance, and maintenance incidents.
- The basic rule for placement: use
%LOCALAPPDATA%(Environment.SpecialFolder.LocalApplicationData) for per-user settings and data,%PROGRAMDATA%for data shared across all users, and never write to the same folder as the executable (under Program Files).1 - There are simply two first-choice formats. Use a JSON file for small, structured settings, and SQLite for growing business data, history, or anything you need to search. These two cover the vast majority of local storage needs for business apps.2
- The registry is “a place for small flags and information that integrates with Windows itself” — it is not your app’s data store. Using it without understanding 32-bit/64-bit registry redirection (
Wow6432Node) will land you in “the value I wrote isn’t there” problems.3 - There is almost no reason left to choose Access (.accdb) as the data store for a new project. Even when it’s used for integration with existing assets, it comes with the deployment constraint that the ACE provider’s bitness must match your application’s.4
- Regardless of format, secrets (passwords, API keys) always need special treatment. Don’t store them as plaintext in JSON or the registry — protect them with DPAPI. For details, see the separate article “Protecting Secrets in Windows Apps - Avoiding Plaintext Settings with DPAPI.”
2. Classifying your data into four types
Before deciding what to store data in, classify the nature of the data you’re about to store. Local data in business apps generally falls into four types.
| Category | Examples | Characteristics |
|---|---|---|
| Settings | Connection targets, screen layout, last-opened folder | Small. Read entirely at startup. Users sometimes want to edit it directly |
| Business data / history | Measurement results, processing history, local copies of master data | Keeps growing. Needs search/aggregation. Corruption has a major business impact |
| Cache | Thumbnails, downloaded resources | Can be regenerated if lost. Needs capacity management |
| Secrets | Saved passwords, tokens | Small in volume. Must never be stored as plaintext |
The core argument of this article is that the right location and format differ for each of these categories. If your app is the kind where “settings and history are all crammed into one XML file,” redoing this classification is the first step toward improvement.
3. Where to put it — the basics of folder selection
In .NET, base your choice on the locations obtainable via Environment.GetFolderPath.5
| Location | How to obtain | Purpose |
|---|---|---|
%LOCALAPPDATA%\CompanyName\AppName |
SpecialFolder.LocalApplicationData |
The default for per-user data. Start here |
%APPDATA%\CompanyName\AppName (Roaming) |
SpecialFolder.ApplicationData |
Only for settings you want to follow the user in a roaming-profile environment |
%PROGRAMDATA%\CompanyName\AppName |
SpecialFolder.CommonApplicationData |
Data shared across all users. Requires ACL design |
| Under Documents | SpecialFolder.MyDocuments |
Only for output the user treats as their own files (exported reports, etc.) |
In code, this is all it takes, but wrapping the “CompanyName\AppName” nesting and first-run folder creation into a shared helper prevents storage locations from proliferating ad hoc.
public static class AppPaths
{
public static string DataDir { get; } = CreateDir(
Environment.SpecialFolder.LocalApplicationData);
private static string CreateDir(Environment.SpecialFolder root)
{
var dir = Path.Combine(
Environment.GetFolderPath(root), "KomuraSoft", "MyApp");
Directory.CreateDirectory(dir); // No-op if it already exists
return dir;
}
}
The reason to use Environment.GetFolderPath instead of building the %LOCALAPPDATA% environment variable through string concatenation is that it returns the correct location even when running under a service account, a different user, or an environment with folder redirection configured. This also avoids the incident where the path changes the moment the app runs under a different account via Task Scheduler (a variant of the “it works when run manually” problem described in Section 5 of the Task Scheduler article).
Here are three pitfalls.
- Don’t write to the folder containing the executable. Standard users cannot write under Program Files. On old 32-bit apps, UAC’s compatibility shim can silently redirect writes to
VirtualStore, causing the baffling symptom where “the contents of the settings file differ depending on whether the app was run as administrator or as a standard user.” - ProgramData is “writable but not safe.” Under the default ACL, one user’s files may not be modifiable by another user. If you need shared read/write access for all users, have the installer create the folder and explicitly configure the ACL.
- Don’t make Roaming the default. In domain environments with roaming user profiles, everything under Roaming is synchronized at logon/logoff. Putting large data or machine-specific data (caches, hardware settings) in Roaming causes sync delays or “contamination” on other machines. When in doubt, use Local.
4. What to store it in — profiling the four options
4.1 JSON files — the first choice for settings
JSON can be read and written straightforwardly with System.Text.Json, is human-readable, and is easy to manage in Git and diff — all advantages that line up well for settings. There are two things to watch out for.
Guard against corruption. If power is lost mid-write, a half-written file can be left behind, making it unreadable the next time the app starts. The standard technique is to “write to a temporary file, then replace” — in .NET, File.Replace provides a replace-with-backup operation.
var json = JsonSerializer.Serialize(settings, options);
var tmp = path + ".tmp";
File.WriteAllText(tmp, json);
if (File.Exists(path))
File.Replace(tmp, path, path + ".bak");
else
File.Move(tmp, path);
If you also build in a degraded-mode behavior on the read side from the start — “if it’s corrupted, try the .bak file, and if that fails too, start with defaults and warn the user” — settings-file corruption stops turning into support tickets.
Don’t turn it into a data store. JSON’s applicable range is sizes where “read everything at startup, write everything at shutdown” still holds (as a rule of thumb, up to a few hundred KB). Once you start putting continually appended history or data that needs record search into JSON, that’s your cue to move to SQLite.
4.2 SQLite — the first choice for growing, searchable data
SQLite is a server-less, single-file, public-domain embedded database. From .NET, you can use Microsoft.Data.Sqlite, the ADO.NET provider maintained by Microsoft, or the EF Core SQLite provider.2 Microsoft itself recommends SQLite as a way to store local data in Windows apps,6 and it’s fair to reach for SQLite first for “structured data that grows locally.”
First, let’s show in code just how lightweight this is. Add Microsoft.Data.Sqlite via NuGet, and you can get started just by specifying a file path — no server setup, no connection-string management screen required.
using Microsoft.Data.Sqlite;
var dbPath = Path.Combine(AppPaths.DataDir, "app.db");
using var conn = new SqliteConnection($"Data Source={dbPath}");
conn.Open();
// First run only: enable WAL mode and create tables
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = """
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS measurement (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
value REAL NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS ix_measurement_device
ON measurement(device_id, created_at);
""";
cmd.ExecuteNonQuery();
}
// Always use parameters for inserts (never build SQL via string concatenation)
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
"INSERT INTO measurement (device_id, value) VALUES ($device, $value)";
cmd.Parameters.AddWithValue("$device", "CAM-01");
cmd.Parameters.AddWithValue("$value", 23.5);
cmd.ExecuteNonQuery();
}
As you can see, for roughly the same effort as “appending to a JSON file,” you get indexed search, aggregation, and unlimited history. If you want an ORM layer, the EF Core SQLite provider sits on top of this library.
With that established, here are the key practical points.
- Enable WAL mode. That’s the
PRAGMA journal_mode=WAL;in the code above. It improves read/write concurrency, so a design where the UI thread and a background process both touch the same database is less likely to stall. The WAL setting is persisted in the database file itself, so you don’t need to issue it on every connection. - Concentrate writes into a single path per process. SQLite writes are exclusive at the database level. If multiple threads need to write, a design that funnels writes through a single queue-backed writer is safer. Also, when performing many small INSERTs, wrapping them in an explicit transaction makes them dramatically faster than committing one row at a time.
- Don’t put it on a network share. File locking over SMB is prone to environment-dependent problems, and the SQLite project itself lists sharing over a network filesystem as the leading cause of corruption.7 Once you need simultaneous access from multiple machines or users, you’ve moved into client-server database territory (SQL Server Express, etc.).
- Know that there are only four types. Under the hood, SQLite has INTEGER / REAL / TEXT / BLOB, and dates and GUIDs are stored as TEXT. Checking
Microsoft.Data.Sqlite’s type-mapping conventions once up front will save you headaches comparing and sorting dates.8 The reason the example above usesdatetime('now')(UTC) forcreated_atis that mixing in local time causes trouble with sorting and daylight-saving transitions. Converting to local time only at display time is the safer policy. - Back up with
VACUUM INTOor the Backup API, not a plain file copy. A naive copy of a live database file can pick up inconsistencies between the WAL and the main file (details in Section 6).
4.3 Registry — only for small flags and Windows integration data
The registry is appropriate for information that integrates with Windows itself — “is it installed,” “startup registration,” “file associations” — and for very small user settings, nothing more. The rule of thumb: settings the app uses for itself go under HKCU, and machine-wide information is written to HKLM by the installer (avoid a design that writes to HKLM at runtime, since that requires administrator privileges).
The biggest pitfall is bitness. On 64-bit Windows, HKLM\Software as seen by a 32-bit process is redirected to HKLM\Software\Wow6432Node.3 Symptoms like “the value is there in Registry Editor but the app can’t read it” or “a value written by the 32-bit app isn’t visible from the 64-bit maintenance tool” are almost always this. It tends to surface when migrating to AnyCPU or moving to 64-bit, so keep it in mind alongside the same 32-bit/64-bit issues that come up with COM and ActiveX (see “Pitfalls of COM/OCX/ActiveX - Visual Studio Bitness and Administrator Privileges”).
If you absolutely need to read the other bitness’s view from .NET (for example, an app still maintained as 32-bit that needs to read a value registered on the 64-bit side), you can specify the view explicitly with RegistryView.
using Microsoft.Win32;
// Read the 64-bit view of HKLM from a 32-bit process
using var hklm64 = RegistryKey.OpenBaseKey(
RegistryHive.LocalMachine, RegistryView.Registry64);
using var key = hklm64.OpenSubKey(@"SOFTWARE\KomuraSoft\MyApp");
var installDir = key?.GetValue("InstallDir") as string;
Conversely, the moment you need this kind of specification is also a sign that you’ve deferred the design decision of “which bitness is the canonical one to write to.” The right approach is to align the bitness of the writer and the reader.
Putting data larger than a few KB, or array-like data, into the registry is a disadvantage from every angle — backup, migration, and diagnostics. Leave that use case to files (JSON / SQLite).
4.4 Access (.accdb) — rarely worth adopting for new projects; accept the trade-offs for legacy integration
Access (JET/ACE) used to be the go-to local database for business apps, but today there’s almost no reason to choose it for new development. The reason is mainly distribution. Accessing a .accdb from code requires the ACE (Access Database Engine) provider, and the connection fails unless the application’s bitness matches ACE’s bitness.4 There are also coexistence issues with Office’s bitness, and “it works on the dev machine but fails at the customer site with The 'Microsoft.ACE.OLEDB.12.0' provider is not registered on the local machine” is a classic support case. Needing to install a redistributable package (the Access Database Engine 2016 Redistributable) also adds to what you have to ship.9
Even so, there are real-world situations where Access is unavoidably involved — data integration with an existing Access-based business system, reading master data built in Access, and so on. In those cases,
- fix the bitness of the process that reads and writes (x86 is often the realistic choice), and have the installer verify the presence of the corresponding ACE
- avoid, as a matter of design, having many people write concurrently to a .accdb on a shared folder (the recovery cost when it breaks isn’t worth it)
- keep a migration path to SQLite or a server-based database for the long term
— these are the trade-offs we’d recommend making peace with. Handling existing assets, including Excel/VBA assets, is also covered in “What Is VBA? - Constraints, Future Prospects, and When to Replace It.”
5. Decision table
| Perspective | JSON file | SQLite | Registry | Access (.accdb) |
|---|---|---|---|---|
| Data it’s good at | Small settings | Growing structured data, search/aggregation | Small flags, Windows integration | Integration with existing Access assets |
| Typical data volume | Up to a few hundred KB | Up to tens of GB | Up to a few KB | Up to 2GB (spec limit) |
| Search/aggregation | ✕ (assumes full read) | ◎ (SQL) | ✕ | ○ (SQL) |
| Human-readable directly | ◎ | △ (needs a tool) | △ | △ (needs Access) |
| Resistance to corruption | △ (must handle yourself) | ○ (transactions) | ○ | △ |
| Concurrent multi-process access | ✕ | ○ (within the same machine) | ○ | △ |
| Sharing across multiple machines | ✕ | ✕ | ✕ | ✕ (in practice) |
| Additional deployment artifacts | None | None (bundled via NuGet) | None | ACE provider required |
As the last row shows, none of these local storage technologies are suited to “sharing across multiple machines.” It may look like putting them on a shared folder gets you sharing, but JSON has no exclusion mechanism, SQLite’s locking over SMB isn’t reliable, and Access hits its limits along with corruption risk. Once a requirement emerges for multiple sites or multiple users touching the same data, treat that as the line where you should stand up a server-based database such as SQL Server Express, or a Web API.
6. Corruption-resistant, migratable, restorable — common design points regardless of format
Whichever format you choose, there are three design elements you will inevitably need if you operate the app for several years. Whether you include them in the first release makes a big difference to later maintenance cost.
6.1 Version your schema/format
When you update the app, the shape of the data you store changes too. The moment where “the new version reads data written by an old version” always arrives, so give the data itself a format version.
In SQLite, PRAGMA user_version exists precisely for this purpose.
int GetVersion(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA user_version";
return Convert.ToInt32(cmd.ExecuteScalar());
}
void Migrate(SqliteConnection conn)
{
void Exec(string sql)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
var v = GetVersion(conn);
if (v > 2)
// Case where an old app opens a DB created by a newer version of the app.
// It's safer to stop here than to touch an unfamiliar schema
throw new InvalidOperationException(
$"This database (version {v}) was created by a newer version of the app.");
using var tx = conn.BeginTransaction();
if (v < 1) Exec("ALTER TABLE measurement ADD COLUMN unit TEXT");
if (v < 2) Exec("CREATE TABLE operator (id INTEGER PRIMARY KEY, name TEXT)");
Exec("PRAGMA user_version = 2");
tx.Commit();
}
This is the minimal form of what’s usually called migration: check the version at startup and apply only the difference. The reason it rejects “a version newer than itself” up front is to prevent an accident where, if the app is rolled back to an older version, the old code writes into a schema it doesn’t understand and corrupts it. The same idea applies to JSON: give the root a "version": 2 field, insert a conversion step from older formats on load, and refuse to load formats that are too new. “Never ship a data format without a version number” — keep just this one rule, and your future self will thank you.
6.2 Decide the degraded-mode behavior for corruption
Section 4 touched on corruption countermeasures for each format (atomic writes for JSON, transactions for SQLite), but even so, you will still encounter “data that can’t be read” — disk failures, quarantine from antivirus false positives, manual edits by users. If you haven’t decided how the app should behave in that situation, you end up with an app that won’t even start.
- If settings can’t be read → start with defaults and notify the user of that fact (silently falling back to defaults turns into “my settings disappeared” inquiries).
- If business data can’t be read → present, via a read-only mode or an error screen, exactly which file is corrupted. Don’t auto-repair by overwriting (it destroys the evidence).
- If a backup exists → offer to restore it. However, automatic restoration carries the flip-side risk of “a false positive for corruption rolling back to old data,” so as a rule, require a user action in between.
6.3 Backups: “can you restore?” matters more than “do you have one?”
Unlike a server database, nobody backs up local data for you. If the app is going to take care of it, decide these three things.
- What: business data is in scope, caches are out of scope; for secrets, keep in mind that DPAPI’s nature means they can only be decrypted on the same user and the same machine (a separate migration procedure is needed for moving to another machine).
- When/where: at startup or daily, into a versioned
backupfolder under%LOCALAPPDATA%. Whether to also place it on a shared folder or an existing PC-backup target folder is a matter for discussion with operations. - How: for SQLite, never do a plain file copy while it’s live.
VACUUM INTO 'backup.db'takes a consistent snapshot in a single statement.
// VACUUM INTO does not create the parent folder, and it errors if the destination already exists.
// Create the folder and settle on a non-colliding file name beforehand
var backupDir = Path.Combine(AppPaths.DataDir, "backup");
Directory.CreateDirectory(backupDir);
var backupPath = Path.Combine(backupDir, $"app-{DateTime.Now:yyyyMMdd-HHmmss}.db");
using var cmd = conn.CreateCommand();
cmd.CommandText = "VACUUM INTO $path";
cmd.Parameters.AddWithValue("$path", backupPath);
cmd.ExecuteNonQuery();
Since generations keep accumulating, also build in a step after each backup that “keeps only the most recent N generations and deletes older ones.”
And do a restore rehearsal at least once. It’s a classic business-system situation: backup files exist, but nobody knows how to restore them, or nobody has ever tried. If you write up a procedure for moving data to a new machine when swapping out a PC, you’ll generally uncover holes in your backup design (DPAPI-protected credentials that don’t transfer, paths that include the username and break under a different user, and so on). For how to erase data when disposing of a PC, also see “What to Do Before Disposing of a Windows PC.”
7. Guidance for common edge cases
- “It’s a setting, but it looks like it’ll grow in the future” — If there’s a chance the “read everything at startup” approach will break down, go with SQLite from the start. There’s nothing wrong with creating a “settings table” in SQLite.
- “Migrating from INI/XML” — If it’s purely a format swap, go to JSON; if history-type data is mixed in at that point, separate it out into SQLite. Keeping a fallback to the old format on the read side for one or two versions makes the migration safer.
- “They’re asking to view it in Excel” — Rather than making Excel/Access the data store, it satisfies both data reliability and the request better to store the data in SQLite and add a feature to export to CSV/Excel. See “How to Build Excel Report Output” for how to build report output.
- “Multiple processes want to read and write the same file” — Within a single machine, SQLite (WAL) can handle quite a lot, but you still need to design for write contention. If you’re coordinating on a file basis, use the exclusion patterns from “File Integration and Locking Best Practices.”
- “We want to share across multiple machines” — This is where you graduate from local storage. The first choice is a client-server setup with SQL Server Express (free, up to 10GB per database) running on a machine equivalent to a file server. Note that SQL Server “LocalDB,” despite its name, is a single-user environment meant for development, so don’t choose it for sharing purposes. Once you’re spanning sites or need access from outside the company, that’s the line for considering a design that goes through a Web API.
8. Summary
Splitting the storage-location decision into “where to put it” (LocalAppData / ProgramData, and never Program Files) and “what to store it in” (JSON for settings, SQLite for growing data, minimal use of the registry, Access only for legacy integration) lets you decide confidently in almost every case.
On top of that, regardless of format, build the three-part set from Section 6 — a format version number, degraded-mode behavior for corruption, and restorable backups — into your very first release. Secrets alone always get separate treatment via DPAPI. Keep the decision table and common design points from this article in mind, and you can avoid nearly all of the expensive-in-hindsight configurations, like “a JSON file that grew to tens of megabytes” or “a shared Access file that breaks once a week.” If you’re uneasy about how an existing app stores its data, we’d recommend starting with an inventory of what is stored where.
Related articles
- Protecting Secrets in Windows Apps - Avoiding Plaintext Settings with DPAPI
- File Integration and Locking Best Practices
- How to Build Excel Report Output - A Decision Table for COM Automation / Open XML / Template Approaches
- Safely Operating Scheduled Tasks with Task Scheduler
Related consulting areas
KomuraSoft LLC handles reviews of data storage approaches for business apps (including migration design away from INI/XML/Access) and investigations into the causes of data corruption and performance degradation.
- Technical Consulting & Design Review
- Legacy Asset Reuse & Migration Support
- Windows App Development
- Contact
References
-
Microsoft Learn, KNOWNFOLDERID. On the definitions of Windows known folders such as LocalAppData, RoamingAppData, and ProgramData. ↩
-
Microsoft Learn, Microsoft.Data.Sqlite overview. On the overview of the ADO.NET provider for SQLite maintained by Microsoft, and its role as the foundation for the EF Core SQLite provider. ↩ ↩2
-
Microsoft Learn, Registry Redirector. On the mechanism by which 32-bit process registry access is redirected to Wow6432Node on 64-bit Windows. ↩ ↩2
-
Microsoft Learn, Can’t establish a connection to Access Database Engine OLE DB. On the requirement that the ACE OLE DB provider’s bitness match that of the accessing process. ↩ ↩2
-
Microsoft Learn, Environment.SpecialFolder Enum. On the enumeration used to obtain known folders from .NET. ↩
-
Microsoft Learn, Use a SQLite database in a Windows app. The official tutorial recommending SQLite along with Microsoft.Data.Sqlite / EF Core for local data storage in Windows apps. ↩
-
SQLite, How To Corrupt An SQLite Database File. On locking failures over network filesystems being a leading cause of database corruption. ↩
-
Microsoft Learn, Data types (Microsoft.Data.Sqlite). On SQLite’s four primitive types and the convention of mapping DateTime and Guid to TEXT. ↩
-
Microsoft, Microsoft Access Database Engine 2016 Redistributable. On the ACE redistributable package (32-bit/64-bit) for accessing .accdb / .mdb files. ↩
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Choosing Windows Inter-Process Communication ── A Decision Table for Named Pipes / TCP / gRPC / Shared Memory / COM
How do you choose the right way for Windows applications to talk to each other? This article organizes named pipes, local TCP, gRPC, shar...
Using SQLite from C# in Business Apps — WAL Mode, Exclusive Locking, Corruption Countermeasures, and When to Reach for EF Core
A practical rundown of embedding SQLite into a business Windows app with Microsoft.Data.Sqlite: connection strings and pooling, how WAL m...
How to Think About Windows Session Isolation — Session 0, RDP, and Running Multiple Users Concurrently
This article untangles the concept of a Windows "session," a topic that consistently confuses Windows app developers. It covers why Sessi...
How to Build and Operate Windows Services ── From Choosing Between Task Scheduler and Services to Turning a BackgroundService into a Windows Service
Should a background process become a Windows service, or is Task Scheduler enough? This guide organizes the practical design work for put...
Preventing Multiple Instances of a Windows App — Named Mutexes and Activating the Existing Window on a Second Launch
This article organizes the classic requirement for business Windows apps — 'don't let the same app launch twice' — around a named Mutex. ...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Technical Consulting & Design Review
We help clarify design direction, architectural boundaries, lifetime ownership, and how to handle legacy Windows assets.
Author Profile
Profile page for the article author.
Go Komura
Representative of KomuraSoft LLC
Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.
Public links