@@ -38,6 +38,91 @@ Run the following command to export most of your data:
3838mysqldump supertokens --fields-terminated-by ' ,' --fields-enclosed-by ' "' --fields-escaped-by ' \' --no-create-info --tab /var/lib/mysql-files/
3939```
4040
41+ :::info
42+
43+ If you do not have permissions to write to the database filesystem you can use the following python script to export tables one by one:
44+ <Accordion >
45+ ## Export script
46+ ``` python
47+ # !/usr/bin/env python3
48+ import subprocess
49+ import os
50+ import csv
51+
52+ DB_HOST = " DB_HOST"
53+ DB_PORT = " 3306"
54+ DB_USER = " DB_USER"
55+ DB_NAME = " DB_NAME"
56+ DB_PASS = " DB_PASS"
57+
58+ def run_mysql_command (query ):
59+ """ Run a mysql command and return the output"""
60+ cmd = [
61+ " mysql" ,
62+ " -h" , DB_HOST ,
63+ " -P" , DB_PORT ,
64+ " -u" , DB_USER ,
65+ f " -p { DB_PASS } " ,
66+ " --batch" ,
67+ " -e" , query,
68+ DB_NAME
69+ ]
70+ result = subprocess.run(cmd, capture_output = True , text = True )
71+ return result.stdout
72+
73+ def main ():
74+ os.makedirs(" ./mysql" , exist_ok = True )
75+
76+ print (" Getting list of tables" )
77+ cmd = [
78+ " mysql" ,
79+ " -h" , DB_HOST ,
80+ " -P" , DB_PORT ,
81+ " -u" , DB_USER ,
82+ f " -p { DB_PASS } " ,
83+ " -N" ,
84+ " -e" , " SHOW TABLES" ,
85+ DB_NAME
86+ ]
87+ result = subprocess.run(cmd, capture_output = True , text = True )
88+
89+ if result.returncode != 0 :
90+ print (f " ERROR: Failed to get table list: { result.stderr} " )
91+ return
92+
93+ tables = [t.strip() for t in result.stdout.strip().split(' \n ' ) if t.strip()]
94+ print (f " Found { len (tables)} tables " )
95+
96+ for table in tables:
97+ print (f " Exporting table: { table} " )
98+
99+ query = f " SELECT * FROM { table} "
100+ output = run_mysql_command(query)
101+
102+ if not output.strip():
103+ print (f " -> Table { table} is empty, skipping " )
104+ continue
105+
106+ lines = output.strip().split(' \n ' )
107+
108+ output_file = f " ./mysql/ { table} .csv "
109+ with open (output_file, ' w' , newline = ' ' ) as f:
110+ csv_writer = csv.writer(f, quoting = csv.QUOTE_MINIMAL )
111+
112+ for line in lines:
113+ fields = line.split(' \t ' )
114+ csv_writer.writerow(fields)
115+
116+ print (f " -> Exported { len (lines)} rows " )
117+
118+ print (" Export complete!" )
119+
120+ if __name__ == " __main__" :
121+ main()
122+ ```
123+ </Accordion >
124+ :::
125+
41126This creates CSV files for all tables in the ` /var/lib/mysql-files/ ` directory.
42127
43128#### 3.2 Export the WebAuthn credentials table
@@ -72,23 +157,110 @@ Connect to your PostgreSQL database and disable triggers to prevent constraint v
72157SET session_replication_role = ' replica' ;
73158```
74159
160+ :::info
161+ If you cannot disable the triggers, use the order specified in the next step.
162+ The * With Order* shows you how to import everything one by one without triggering the constraints.
163+ :::
164+
75165#### 5.2 Import the standard tables
76166
77167For most tables, you can import the data directly.
78168
79169<Tabs >
80170<TabItem value = " without-order" label = " Without specifying column order" >
81171``` sql
82- COPY app_id_to_user_id FROM ' /pg-data-host/app_id_to_user_id.txt '
172+ COPY < table_name > FROM ' /pg-data-host/<table_name>.csv '
83173CSV DELIMITER ' ,' QUOTE ' "' ESCAPE ' \' NULL as ' \N' ;
84174```
85175</TabItem>
86176
87177<TabItem value="with-order" label="With specifying column order">
88- ```sql
89- COPY app_id_to_user_id(app_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id)
90- FROM ' / pg- data- host/ app_id_to_user_id .txt '
91- CSV DELIMITER ' ,' QUOTE ' " ' ESCAPE '\' NULL as '\N';
178+ ```bash
179+ #!/bin/bash
180+
181+ PG_HOST="PG_HOST"
182+ PG_PORT="5432"
183+ PG_USER="PG_USER"
184+ PG_DB="PG_DB"
185+ PG_PASS="PG_PASS"
186+ CSV_DIR="./mysql"
187+
188+ import_table() {
189+ local table=$1
190+ local columns=$2
191+ local csv_file="${CSV_DIR}/${table}.csv"
192+
193+ if [ ! -f "$csv_file" ]; then
194+ echo "File not found: $csv_file, skipping"
195+ return
196+ fi
197+
198+ echo "Importing table: $table"
199+
200+ if [ -z "$columns" ] || [ "$columns" = "*" ]; then
201+ PGPASSWORD=$PG_PASS psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB -c \
202+ "\\ COPY $table FROM ' $csv_file' WITH (FORMAT csv, HEADER true);"
203+ else
204+ PGPASSWORD=$PG_PASS psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB -c \
205+ "\\ COPY $table ($columns) FROM ' $csv_file' WITH (FORMAT csv, HEADER true);"
206+ fi
207+
208+ if [ $? -eq 0 ]; then
209+ echo "Successfully imported $table"
210+ else
211+ echo "ERROR importing $table"
212+ fi
213+ }
214+
215+ echo "Starting PostgreSQL import"
216+
217+ import_table "apps" ""
218+ import_table "tenants" ""
219+ import_table "key_value" "app_id, tenant_id, name, value, created_at_time"
220+ import_table "all_auth_recipe_users" "app_id, tenant_id, user_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user, recipe_id, time_joined, primary_or_recipe_user_time_joined"
221+ import_table "app_id_to_user_id" "app_id, user_id, recipe_id, primary_or_recipe_user_id, is_linked_or_is_a_primary_user"
222+ import_table "bulk_import_users" "id, app_id, primary_user_id, raw_data, status, error_msg, created_at, updated_at"
223+ import_table "dashboard_user_sessions" "app_id, session_id, user_id, time_created, expiry"
224+ import_table "dashboard_users" "app_id, user_id, email, password_hash, time_joined"
225+ import_table "emailpassword_pswd_reset_tokens" "app_id, user_id, token, email, token_expiry"
226+ import_table "emailpassword_user_to_tenant" "app_id, tenant_id, user_id, email"
227+ import_table "emailpassword_users" "app_id, user_id, email, password_hash, time_joined"
228+ import_table "emailverification_tokens" "app_id, tenant_id, user_id, email, token, token_expiry"
229+ import_table "emailverification_verified_emails" "app_id, user_id, email"
230+ import_table "jwt_signing_keys" "app_id, key_id, key_string, algorithm, created_at"
231+ import_table "oauth_clients" "app_id, client_id, client_secret, enable_refresh_token_rotation, is_client_credentials_only"
232+ import_table "oauth_logout_challenges" "app_id, challenge, client_id, post_logout_redirect_uri, session_handle, state, time_created"
233+ import_table "oauth_m2m_tokens" "app_id, client_id, iat, exp"
234+ import_table "oauth_sessions" "gid, app_id, client_id, session_handle, external_refresh_token, internal_refresh_token, jti, exp"
235+ import_table "passwordless_codes" "app_id, tenant_id, code_id, device_id_hash, link_code_hash, created_at"
236+ import_table "passwordless_devices" "app_id, tenant_id, device_id_hash, email, phone_number, link_code_salt, failed_attempts"
237+ import_table "passwordless_user_to_tenant" "app_id, tenant_id, user_id, email, phone_number"
238+ import_table "passwordless_users" "app_id, user_id, email, phone_number, time_joined"
239+ import_table "role_permissions" "app_id, role, permission"
240+ import_table "roles" "app_id, role"
241+ import_table "session_access_token_signing_keys" "app_id, created_at_time, value"
242+ import_table "session_info" "app_id, tenant_id, session_handle, user_id, refresh_token_hash_2, session_data, expires_at, created_at_time, jwt_user_payload, use_static_key"
243+ import_table "tenant_first_factors" "connection_uri_domain, app_id, tenant_id, factor_id"
244+ import_table "tenant_required_secondary_factors" "connection_uri_domain, app_id, tenant_id, factor_id"
245+ import_table "tenant_thirdparty_providers" "connection_uri_domain, app_id, tenant_id, third_party_id, name, authorization_endpoint, authorization_endpoint_query_params, token_endpoint, token_endpoint_body_params, user_info_endpoint, user_info_endpoint_query_params, user_info_endpoint_headers, jwks_uri, oidc_discovery_endpoint, require_email, user_info_map_from_id_token_payload_user_id, user_info_map_from_id_token_payload_email, user_info_map_from_id_token_payload_email_verified, user_info_map_from_user_info_endpoint_user_id, user_info_map_from_user_info_endpoint_email, user_info_map_from_user_info_endpoint_email_verified"
246+ import_table "thirdparty_user_to_tenant" "app_id, tenant_id, user_id, third_party_id, third_party_user_id"
247+ import_table "thirdparty_users" "app_id, third_party_id, third_party_user_id, user_id, email, time_joined"
248+ import_table "totp_used_codes" "app_id, tenant_id, user_id, code, is_valid, expiry_time_ms, created_time_ms"
249+ import_table "tenant_configs" "connection_uri_domain, app_id, tenant_id, core_config, email_password_enabled, passwordless_enabled, third_party_enabled, is_first_factors_null"
250+ import_table "totp_user_devices" "app_id, user_id, device_name, secret_key, period, skew, verified, created_at"
251+ import_table "totp_users" "app_id, user_id"
252+ import_table "user_last_active" "app_id, user_id, last_active_time"
253+ import_table "user_metadata" "app_id, user_id, user_metadata"
254+ import_table "user_roles" "app_id, tenant_id, user_id, role"
255+ import_table "userid_mapping" "app_id, supertokens_user_id, external_user_id, external_user_id_info"
256+ import_table "webauthn_account_recovery_tokens" "app_id, tenant_id, user_id, email, token, expires_at"
257+ import_table "webauthn_generated_options" "app_id, tenant_id, id, challenge, email, rp_id, rp_name, origin, expires_at, created_at, user_presence_required, user_verification"
258+ import_table "webauthn_user_to_tenant" "app_id, tenant_id, user_id, email"
259+ import_table "webauthn_users" "app_id, user_id, email, rp_id, time_joined"
260+
261+ echo ""
262+ echo "Import complete!"
263+
92264```
93265</TabItem>
94266
@@ -210,11 +382,85 @@ SET session_replication_role = 'origin';
210382
211383Verify that all data migrated successfully by comparing record counts between your MySQL and PostgreSQL databases:
212384
213- ```sql
214- -- Run on both databases
215- SELECT COUNT(*) FROM users;
216- SELECT COUNT(*) FROM sessions;
217- -- Add other tables as needed
385+ ```bash
386+ #!/bin/bash
387+
388+ MYSQL_HOST="DB_HOST"
389+ MYSQL_PORT="3306"
390+ MYSQL_USER="DB_USER"
391+ MYSQL_DB="DB_NAME"
392+ MYSQL_PASS="DB_PASS"
393+
394+ PG_HOST="PG_HOST"
395+ PG_PORT="5432"
396+ PG_USER="PG_USER"
397+ PG_DB="PG_DB"
398+ PG_PASS="PG_PASS"
399+
400+ echo "Comparing table row counts between MySQL and PostgreSQL"
401+
402+ echo "Getting table list from MySQL"
403+ TABLES=$(mysql -h $MYSQL_HOST -P $MYSQL_PORT -u $MYSQL_USER -p$MYSQL_PASS \
404+ -N -e "SHOW TABLES" $MYSQL_DB)
405+
406+ if [ $? -ne 0 ]; then
407+ echo "ERROR: Failed to get table list from MySQL"
408+ exit 1
409+ fi
410+
411+ TOTAL_TABLES=$(echo "$TABLES" | wc -l)
412+ echo "Found $TOTAL_TABLES tables"
413+ echo ""
414+
415+ MATCH_COUNT=0
416+ MISMATCH_COUNT=0
417+ ERROR_COUNT=0
418+
419+ for TABLE in $TABLES; do
420+ MYSQL_COUNT=$(mysql -h $MYSQL_HOST -P $MYSQL_PORT -u $MYSQL_USER -p$MYSQL_PASS \
421+ -N -e "SELECT COUNT(*) FROM $TABLE" $MYSQL_DB 2>&1)
422+
423+ if [ $? -ne 0 ]; then
424+ echo "$TABLE - MySQL: ERROR, PostgreSQL: -, Status: ERROR"
425+ ERROR_COUNT=$((ERROR_COUNT + 1))
426+ continue
427+ fi
428+
429+ PG_COUNT=$(PGPASSWORD=$PG_PASS psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB \
430+ -t -c "SELECT COUNT(*) FROM $TABLE" 2>&1)
431+
432+ if [ $? -ne 0 ]; then
433+ echo "$TABLE - MySQL: $MYSQL_COUNT, PostgreSQL: ERROR, Status: ERROR"
434+ ERROR_COUNT=$((ERROR_COUNT + 1))
435+ continue
436+ fi
437+
438+ MYSQL_COUNT=$(echo $MYSQL_COUNT | xargs)
439+ PG_COUNT=$(echo $PG_COUNT | xargs)
440+
441+ if [ "$MYSQL_COUNT" = "$PG_COUNT" ]; then
442+ echo "$TABLE - MySQL: $MYSQL_COUNT, PostgreSQL: $PG_COUNT, Status: ✓ MATCH"
443+ MATCH_COUNT=$((MATCH_COUNT + 1))
444+ else
445+ echo "$TABLE - MySQL: $MYSQL_COUNT, PostgreSQL: $PG_COUNT, Status: ✗ MISMATCH"
446+ MISMATCH_COUNT=$((MISMATCH_COUNT + 1))
447+ fi
448+ done
449+
450+ echo "Summary:"
451+ echo " Total tables: $TOTAL_TABLES"
452+ echo " Matching: $MATCH_COUNT"
453+ echo " Mismatched: $MISMATCH_COUNT"
454+ echo " Errors: $ERROR_COUNT"
455+ echo ""
456+
457+ if [ $MISMATCH_COUNT -eq 0 ] && [ $ERROR_COUNT -eq 0 ]; then
458+ echo "✓ All tables match!"
459+ exit 0
460+ else
461+ echo "✗ Some tables have mismatches or errors"
462+ exit 1
463+ fi
218464```
219465
220466If the numbers match, you have successfully migrated your SuperTokens data from `MySQL` to `PostgreSQL` :tada:
0 commit comments