Foundry VTT Data Storage
Domain: Foundry VTT Module/System Development Status: Production-Ready Last Updated: 2026-01-04
Overview
Foundry VTT provides three primary storage mechanisms: Flags (document-attached), Settings (global config), and Files (external storage). Choosing the wrong method is a common source of bugs and performance issues.
When to Use This Skill
- Deciding where to store module/system data
- Implementing document-specific custom properties
- Creating module configuration options
- Handling large datasets that impact performance
- Migrating data between storage types
Quick Decision Matrix
| Need | Use | Why | |------|-----|-----| | Data on specific document | Flags | Travels with document, respects permissions | | Global module config | Settings (world) | Synced to all clients, GM-controlled | | Per-device preference | Settings (client) | localStorage, user-specific | | Large datasets | Files | No performance impact on documents | | Export/import data | Files | Portable, shareable |
Flags
Flags attach key-value data to Documents (Actors, Items, Scenes, etc.).
Basic Usage
// Set a flag
await actor.setFlag('my-module', 'customProperty', { value: 42 });
// Get a flag
const data = actor.getFlag('my-module', 'customProperty');
// data === { value: 42 }
// Delete a flag
await actor.unsetFlag('my-module', 'customProperty');
// Direct access (read-only)
const value = actor.flags['my-module']?.customProperty;
Namespacing
Always use your module ID as the scope:
// CORRECT - Uses module ID
await doc.setFlag('my-module-id', 'flagName', value);
// WRONG - Generic scope causes collisions
await doc.setFlag('world', 'flagName', value);
Batch Updates
// BAD - Three database writes
await actor.setFlag('myModule', 'flag1', value1);
await actor.setFlag('myModule', 'flag2', value2);
await actor.setFlag('myModule', 'flag3', value3);
// GOOD - Single database write
await actor.update({
'flags.myModule.flag1': value1,
'flags.myModule.flag2': value2,
'flags.myModule.flag3': value3
});
Nested Flag Operations
// Delete nested key (V10+)
await doc.unsetFlag('myModule', 'todos.completedItem');
// Alternative: Foundry deletion syntax
await doc.setFlag('myModule', 'todos', { '-=completedItem': null });
Pitfalls
1. Periods in Object Keys Break getFlag:
// BROKEN - Period in key causes issues
await doc.setFlag('myModule', 'data', { 'some.key': 'value' });
const result = doc.getFlag('myModule', 'data');
// result !== { 'some.key': 'value' } - Data corrupted!
// WORKAROUND - Use class instance (treated as complex object)
class MyData {
constructor(data) { Object.assign(this, data); }
}
await doc.setFlag('myModule', 'data', new MyData({ 'some.key': 'value' }));
2. Inactive Module Throws Error:
// UNSAFE - Throws if module not installed
const value = doc.getFlag('optional-module', 'flag');
// SAFE - Handle missing module
const value = doc.flags['optional-module']?.flag ?? defaultValue;
Settings
Settings store global configuration with different scopes.
Scope Types
| Scope | Storage | Editable By | Synced | Use For |
|-------|---------|-------------|--------|---------|
| client | localStorage | Any user | No | UI prefs, device settings |
| world | Database | GM only | Yes | Module config, rules |
| user (V13+) | Database | That user | Yes | Per-user cross-device |
Registration
Hooks.once('init', () => {
// Client setting - per-device
game.settings.register('myModule', 'theme', {
name: 'UI Theme',
hint: 'Select your preferred theme',
scope: 'client',
config: true,
type: String,
choices: {
light: 'Light',
dark: 'Dark'
},
default: 'light',
onChange: value => applyTheme(value)
});
// World setting - shared, GM-only
game.settings.register('myModule', 'enableFeature', {
name: 'Enable Feature',
hint: 'Turns on the special feature for all users',
scope: 'world',
config: true,
type: Boolean,
default: false,
requiresReload: true // V10+ prompts user to reload
});
});
Hidden Settings with Menus
For complex config, hide the setting and use a FormApplication:
// 1. Register menu button
game.settings.registerMenu('myModule', 'configMenu', {
name: 'Advanced Configuration',
label: 'Configure',
icon: 'fas fa-cog',
type: MyConfigApp,
restricted: true // GM only
});
// 2. Register hidden backing setting
game.settings.register('myModule', 'config', {
scope: 'world',
config: false, // Hidden from settings UI
type: Object,
default: { option1: true, threshold: 10 }
});
// 3. Access in FormApplication
class MyConfigApp extends FormApplication {
getData() {
return game.settings.get('myModule', 'config');
}
async _updateObject(event, formData) {
await game.settings.set('myModule', 'config',
foundry.utils.expandObject(formData)
);
}
}
Setting Types
// Choices dropdown
game.settings.register('myModule', 'mode', {
type: String,
choices: { a: 'Option A', b: 'Option B' },
default: 'a'
});
// Number with range slider
game.settings.register('myModule', 'volume', {
type: Number,
range: { min: 0, max: 100, step: 5 },
default: 50
});
// File picker
game.settings.register('myModule', 'backgroundImage', {
type: String,
filePicker: 'image', // 'audio', 'video', 'any'
default: ''
});
// DataModel for validation (recommended)
game.settings.register('myModule', 'validated', {
type: MyDataModel,
default: {}
});
onChange Behavior
// Client scope: fires only on this client
game.settings.register('myModule', 'clientSetting', {
scope: 'client',
onChange: value => {
// Only runs locally
}
});
// World scope: fires on ALL clients
game.settings.register('myModule', 'worldSetting', {
scope: 'world',
onChange: value => {
// Runs everywhere when GM changes it
// Re-fetch to ensure consistency
const current = game.settings.get('myModule', 'worldSetting');
}
});
Files
Use file storage for large datasets or exportable data.
When to Use Files
- Data > 100KB that would slow document operations
- Export/import functionality
- Asset management (images, audio)
- Sharing data between worlds
FilePicker Upload
// Upload a file
const file = new File(
[JSON.stringify(data, null, 2)],
'export.json',
{ type: 'application/json' }
);
await FilePicker.upload(
'data', // source: 'data', 'public', 's3'
'myModule/data', // target directory
file,
{},
{ notify: true }
);
Reading Files
// Fetch and parse JSON
async function loadData(path) {
const response = await fetch(path);
if (!response.ok) throw new Error(`Failed to load ${path}`);
return response.json();
}
const data = await loadData('modules/myModule/data/config.json');
Lazy Loading Pattern
Store file reference in flag, load on demand:
// Store reference
await actor.setFlag('myModule', 'dataFile', 'myModule/data/actor-123.json');
// Load when needed
async function getActorData(actor) {
const path = actor.getFlag('myModule', 'dataFile');
if (!path) return null;
return loadData(path);
}
Common Mistakes
Wrong: Flags for Module Config
// BAD - Config doesn't belong on a random document
await game.user.setFlag('myModule', 'globalConfig', config);
// GOOD - Use world setting
game.settings.register('myModule', 'globalConfig', {
scope: 'world',
config: false,
type: Object
});
Wrong: Large Data in Flags
// BAD - Slows every actor update
await actor.setFlag('myModule', 'history', arrayWith10000Entries);
// GOOD - Store in file, reference in flag
const file = new File([JSON.stringify(history)], `${actor.id}-history.json`);
await FilePicker.upload('data', 'myModule/history', file);
await actor.setFlag('myModule', 'historyFile', `myModule/history/${actor.id}-history.json`);
Wrong: Client Setting for Shared State
// BAD - Each user sees different value
game.settings.register('myModule', 'gameRule', {
scope: 'client', // Wrong scope!
type: Boolean
});
// GOOD - World scope for shared rules
game.settings.register('myModule', 'gameRule', {
scope: 'world',
type: Boolean
});
Migration Between Storage Types
Flag to Setting
Hooks.once('ready', async () => {
const version = game.settings.get('myModule', 'schemaVersion') ?? 0;
if (version < 2) {
// Collect data from actor flags
const migrated = {};
for (const actor of game.actors) {
const old = actor.getFlag('myModule', 'oldData');
if (old) {
migrated[actor.id] = old;
await actor.unsetFlag('myModule', 'oldData');
}
}
// Store in setting
await game.settings.set('myModule', 'migratedData', migrated);
await game.settings.set('myModule', 'schemaVersion', 2);
ui.notifications.info('MyModule: Migration complete');
}
});
Setting to File (Large Data)
async function migrateToFile() {
const largeData = game.settings.get('myModule', 'bigSetting');
// Export to file
const file = new File(
[JSON.stringify(largeData, null, 2)],
'migrated-data.json',
{ type: 'application/json' }
);
await FilePicker.upload('data', 'myModule', file);
// Update setting to path reference
await game.settings.set('myModule', 'dataPath', 'myModule/migrated-data.json');
await game.settings.set('myModule', 'bigSetting', null);
}
Implementation Checklist
- [ ] Use module ID as flag scope (never 'world' or generic names)
- [ ] Register settings in
inithook - [ ] Use
scope: 'world'for shared config,scope: 'client'for preferences - [ ] Batch flag updates with
document.update()when setting multiple - [ ] Use files for data > 100KB
- [ ] Handle missing flags/settings with defaults
- [ ] Add
requiresReload: truefor settings that need refresh (V10+) - [ ] Use DataModel for setting validation when possible
References
Last Updated: 2026-01-04 Status: Production-Ready Maintainer: ImproperSubset
Scan to join WeChat group