Disclosure: Navicat gave me free Premium access for this review and offered a one-year Premium subscription (about US$1,000) if I published. Navicat did not see or approve this text, and told me the review did not have to be positive.
I tested Navicat Premium 18 (AppImage) on Ubuntu against four engines: a local Postgres 16 container, a SQLite file, an Oracle Autonomous Database on Oracle Cloud's Always Free tier (Oracle AI Database 26ai), and a MySQL HeatWave database on the same cloud. Everything used invented finance-style data. I include the SQL and the setup commands, so you can repeat each test.
The short version
- Worked well: Postgres to Postgres transfer and synchronization, with the exact SQL shown before anything ran. Oracle to Oracle transfer, structure sync and data sync also worked. On MySQL HeatWave, Data Transfer, Structure Synchronization, Data Synchronization and Data Masking all finished with 0 errors.
-
Needed manual checks: Postgres to SQLite lost auto-numbering and defaults. Postgres to MySQL kept the keys and the foreign key but turned
SERIALinto a plainintand dropped thenow()default. Postgres to Oracle mapped timestamps toDATE. Thecustomerstransfer failed when Navicat's generatedTO_DATEexpression received fractional seconds its format did not allow, and trimming the source timestamps to whole seconds got those rows through. -
Rough edges on Linux: connecting to an Autonomous Database took a wallet, Oracle Instant Client and a
libaiolink, and the Net Service Name dropdown stayed empty. The SSH tunnel to MySQL HeatWave showed a key passphrase prompt although my key has none. - AI features: they work with a model you bring, and the results depend on it. I used a free Nvidia model through OpenRouter.
- Not tested: real or large data, other platforms and stronger AI models.
What I tested and how
- When and where: November 2026, with Navicat Premium 18 as an AppImage on Ubuntu.
-
Data: a
customerstable (8 rows, with name, email, phone, national ID and county) and atransactionstable (200 rows, with amounts in KES). Larger copies,customers_ai(about 1,000 generated rows) andtransactions_ai(200 rows), were used for the AI and transfer tests. - Everything was fake. Nothing real went through Navicat or through any AI provider.
- AI model: a free Nvidia Nemotron model reached through OpenRouter. Frontier models such as Claude from Anthropic, Kimi from Moonshot and GPT from OpenAI typically need API keys and may incur usage charges, which is why I chose a free one. It is not a model Navicat bundles, so anything below about AI output reflects that choice.
Data Masking
Data Masking lives under Tools and worked on Postgres, but partial masking keeps fragments of the original, so choose techniques by risk.
I picked a technique for each column from a dropdown. Most of my choices produced partial masking: asterisks replace most characters while the format stays realistic.
| Column | What the output looked like | What still leaks |
|---|---|---|
full_name |
A**** W******* |
initial letters and name length |
email |
a*****w@e**********m |
first and last character of the local part, last character of the domain |
phone |
+25******0001 |
country prefix and last 4 digits |
national_id |
1******* on one copy, NULL on another |
one digit when partial, nothing when nulled |
county, customer_id, created_at
|
left unmasked on purpose | not applicable |
I left customer_id alone because masking a key would break the link to transactions. Nulling the national ID was the safest result, but it only works on columns that allow NULL, so it would fail on full_name. Partial masking is fine for stopping casual viewing in test data. For anything that must resist re-identification, use a substitution technique or null the column.
Queries I used for the masking test
The masked output went into a copy of customers called customers_ai, so I could compare it with the original. I first created the copy with AS TABLE, which copies the columns and rows but not the keys or defaults, then dropped it and used LIKE ... INCLUDING ALL, which copies the structure with its constraints, defaults and indexes but no rows.
CREATE TABLE customers_ai AS TABLE customers;
DROP TABLE customers_ai;
CREATE TABLE customers_ai (LIKE customers INCLUDING ALL);
To see what each technique did, I joined the original and masked tables side by side:
SELECT o.customer_id,
o.full_name AS original_name, m.full_name AS masked_name,
o.email AS original_email, m.email AS masked_email
FROM customers o
JOIN customers_ai m USING (customer_id);
For the AI tests I wanted realistic names again, so I replaced the masked names in customers_ai by cycling through a list of 20 invented Kenyan names:
UPDATE customers_ai ai
SET full_name = np.full_name
FROM (
SELECT customer_id,
row_number() OVER (ORDER BY customer_id) AS rn
FROM customers_ai
) t
JOIN (
SELECT full_name,
row_number() OVER () AS rn,
count(*) OVER () AS total
FROM (VALUES
('Wanjiku Kamau'),
('Otieno Odhiambo'),
('Njeri Mwangi'),
('Mwende Mutiso'),
('Akinyi Odongo'),
('Muthoni Karuki'),
('Kibet Rotich'),
('Atieno Achieng'),
('Maina Njoroge'),
('Wekesa Barasa'),
('Nafula Wanjala'),
('Kamau Ndungu'),
('Achieng Ouko'),
('Mutuku Kilonzo'),
('Wambui Githinji'),
('Odhiambo Onyango'),
('Nduta Kimani'),
('Korir Ruto'),
('Auma Otieno'),
('Chepkorir Langat')
) AS v(full_name)
) np ON np.rn = ((t.rn - 1) % np.total) + 1
WHERE ai.customer_id = t.customer_id;
SELECT customer_id, full_name
FROM customers_ai
ORDER BY customer_id
LIMIT 10;
The VALUES list has no explicit numbering, so Postgres does not guarantee the order in which the names are cycled. Give each name an explicit number if you need a reproducible result.
Postgres to Postgres: generation, transfer and synchronization
Between two Postgres databases (my_project_db as the source and my_target_db as the target), Navicat copied and synced my data correctly and showed the exact SQL before running anything.
- Data Generation: produced about 1,000 rows, and Navicat reported 0.00186 seconds for it.
-
Data Transfer: I copied
customers,customers_ai,transactionsand the two sequences behind theSERIALcolumns into the second database. Row counts matched, with 200 rows intransactionson both sides. -
Data Synchronization: after I changed one county in the source, the comparison found exactly one row to update and nothing to insert or delete. It listed
customers(8 rows) andtransactions(200 rows) as identical. After running it, the changed value appeared in the target. -
Structure Synchronization: it flagged the
risk_scorecolumn I had added and showed theALTER TABLEscript before applying it.
Two cautions from the previews:
- The data sync script updated every column of the changed row, not only the county. On a busy table, that could overwrite someone else's recent edit to another column.
- Sync copies whatever the source holds, including masked values. Mask before you sync to a test environment, not after.
A Recompare button and a per-table tick box made it easy to check and limit what would run.
Queries I used on these tests
The first two statements built the _ai copy of transactions. The next two checked the transfer on both sides. The ALTER TABLE is the schema change I used for Structure Synchronization, and the last query reads the county of the row I changed for Data Synchronization.
CREATE TABLE transactions_ai (LIKE transactions INCLUDING ALL);
INSERT INTO transactions_ai SELECT * FROM transactions;
SELECT count(*) FROM transactions;
SELECT round(sum(amount_kes), 2) FROM transactions;
ALTER TABLE customers_ai ADD COLUMN risk_score INT;
SELECT county FROM customers_ai WHERE customer_id = 1002;
Postgres to SQLite
The data and keys survived a move to a different engine, but auto-increment and column defaults did not, so check them by hand after any cross-engine transfer.
I transferred the tables from Postgres into a new SQLite file with Data Transfer, leaving the sequences unticked because SQLite has none.
| Check | Result |
|---|---|
| Row counts | Matched |
Sum of amount_kes over 200 rows |
5121876.44 in both engines |
Primary key on customer_id
|
Kept |
NOT NULL on customer_id and full_name
|
Kept |
Foreign key from transactions to customers
|
Kept |
Auto-numbering on customer_id
|
Lost |
Default on created_at
|
Lost |
| Types |
int4 became integer(32), varchar(n) became text(n), timestamps became text(6)
|
The lost auto-numbering showed up as soon as I inserted a row without an id: SQLite rejected it with a NOT NULL constraint failure. I suspect the cause is that the column arrived as integer(32) and not as a plain INTEGER PRIMARY KEY, which is the form SQLite fills in itself, but I did not confirm that.
Queries I used on the SQLite copy
SELECT round(sum(amount_kes), 2) FROM transactions;
SELECT count(*) FROM transactions;
PRAGMA table_info(customers);
PRAGMA foreign_key_list(transactions);
SELECT sql FROM sqlite_master WHERE name = 'customers';
SELECT * FROM customers LIMIT 3;
SELECT customer_id FROM customers ORDER BY customer_id DESC LIMIT 1;
When I asked the AI to correct the failed insert, it declined to guess. Its reply was a comment saying it could not safely correct the statement, because customers.customer_id is NOT NULL and the insert supplied no value or default, and it asked for the table schema or a valid customer_id.
Postgres to Oracle Autonomous Database
Oracle was the hardest test: getting connected took real work on Linux, and the transfer failed on timestamps until I changed the source data.
Connecting from Linux
An Autonomous Database needs a downloaded wallet and an Oracle client library, so the setup is more than a hostname and password. These are the commands that got me connected, in the order that worked. Replace <your_db> with your database name, and run steps 4 to 6 in one terminal so the IC variable stays set.
Step 1. Check whether an Instant Client is already on the machine.
find ~ /opt /usr/lib -name 'libclntsh.so*' 2>/dev/null
Step 2. Unzip the Instant Client. I used the 23.26 Basic zip (about 137 MB) from Oracle's download page.
mkdir -p ~/oracle
unzip -q ~/Downloads/instantclient-basic-linux.x64-23.26.4.0.0.zip -d ~/oracle
ls -l ~/oracle/instantclient_23_26/libclntsh.so*
Step 3. Unzip the wallet you downloaded from the Oracle Cloud console.
mkdir -p ~/network/admin
unzip ~/Downloads/Wallet_<your_db>.zip -d ~/network/admin
Step 4. Point the wallet's sqlnet.ora at a real path, and copy the wallet into the Instant Client.
IC=~/oracle/instantclient_23_26
sed -i "s|DIRECTORY=\"[^\"]*\"|DIRECTORY=\"$HOME/network/admin\"|" ~/network/admin/sqlnet.ora
mkdir -p $IC/network/admin
cp ~/network/admin/* $IC/network/admin/
sed -i "s|DIRECTORY=\"[^\"]*\"|DIRECTORY=\"$IC/network/admin\"|" $IC/network/admin/sqlnet.ora
cat -A ~/network/admin/sqlnet.ora
cat -A $IC/network/admin/sqlnet.ora
Each file should hold two lines: the WALLET_LOCATION line with your path, and SSL_SERVER_DN_MATCH=yes. One of my copies lost the second line, so if yours is missing it, add it back:
printf '\nSSL_SERVER_DN_MATCH=yes\n' >> ~/network/admin/sqlnet.ora
Step 5. Ask Linux which libraries the client cannot find.
ldd $IC/libclntsh.so.23.1 | grep "not found"
Mine listed libnnz.so, libaio.so.1 and libclntshcore.so.23.1. The first and last live in the Instant Client folder itself, and libaio needs a link because Ubuntu names it libaio.so.1t64.
Step 6. Register the folder, link libaio, and check again.
echo $IC | sudo tee /etc/ld.so.conf.d/oracle-instantclient.conf
sudo ldconfig
sudo ln -s /usr/lib/x86_64-linux-gnu/libaio.so.1t64 /usr/lib/x86_64-linux-gnu/libaio.so.1
ldd $IC/libclntsh.so.23.1 | grep "not found"
The last command should print nothing. A link placed inside the Instant Client folder did not work for libaio, because ldconfig ignores it there.
Step 7. List the service names in the wallet.
grep -oE '^[A-Za-z0-9_]+ *=' $IC/network/admin/tnsnames.ora
You should see five aliases ending in _high, _medium, _low, _tp and _tpurgent.
Step 8. Set up Navicat.
- In Tools > Options > Environment, set the OCI library to
/home/<you>/oracle/instantclient_23_26/libclntsh.so. Leave "Use bundled OCI library" unticked, then quit Navicat completely and reopen it. - Create an Oracle connection with Connection Type TNS, type the Net Service Name by hand (I used the
_lowalias), enter your user, and type the password yourself. - Click Test Connection.
Two things confused me. The Net Service Name dropdown stayed empty even though tnsnames.ora was valid, but typing the alias by hand worked, and Navicat then filled in the full Service Name itself. And with no alias set, the error was ORA-12547 (lost contact), which says nothing about a missing service name. A later ORA-01017 simply meant the password I typed did not match the one stored in the database, so check that before blaming the wallet.
Also worth knowing for a trial: Always Free databases stop after 7 days of inactivity and need a manual Start in the Oracle console. Oracle's documentation also says a database that is stopped and stays inactive for 90 days, cumulative, may be reclaimed and permanently deleted, so start it again before then if you want to keep it.
Creating the test user
I ran this in Database Actions as ADMIN. Choose your own password, 12 to 30 characters with an upper-case letter, a lower-case letter and a digit, and type it without the angle brackets. My first attempt kept the brackets as part of the password, and every login then failed with ORA-01017.
CREATE USER navicat_test IDENTIFIED BY "<your password>";
GRANT DWROLE TO navicat_test;
ALTER USER navicat_test QUOTA UNLIMITED ON DATA;
To change the password later:
ALTER USER navicat_test IDENTIFIED BY "<new password>";
To check the account and the session from ADMIN:
SELECT username, account_status, authentication_type FROM dba_users WHERE username = 'NAVICAT_TEST';
SELECT granted_role FROM dba_role_privs WHERE grantee = 'NAVICAT_TEST';
SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM dual;
After I edited a duplicated connection to use navicat_test, an open query tab still returned ADMIN. Navicat keeps the old session alive, so close the tab and the connection, then reopen the connection.
What the transfer showed
I transferred four tables from Postgres into the new schema. The final row counts matched the source, but only after the timestamp problem below.
| Check | Result |
|---|---|
| Row counts |
customers 8, customers_ai 1000, transactions 200, transactions_ai 200, matching Postgres |
Sum of amount_kes on transactions
|
5121876.44 in Oracle and in Postgres |
| Table names | created as quoted lower-case names, so queries need "customers" in double quotes |
| Integers |
integer became NUMBER(11)
|
| Text |
varchar(n) became NVARCHAR2(n)
|
| Money |
numeric(12,2) became plain NUMBER, so the precision and scale were dropped |
| Timestamps |
timestamp became DATE
|
| Auto-numbering and defaults | the generated CREATE TABLE had no identity column and no defaults |
I only compared the amount sum for transactions, not for transactions_ai.
Queries I used to check the transfer
On Oracle, as navicat_test:
SELECT 'customers' AS tbl, COUNT(*) AS n FROM "customers"
UNION ALL SELECT 'customers_ai', COUNT(*) FROM "customers_ai"
UNION ALL SELECT 'transactions', COUNT(*) FROM "transactions"
UNION ALL SELECT 'transactions_ai', COUNT(*) FROM "transactions_ai";
SELECT COUNT(*), SUM("amount_kes") FROM "transactions";
SELECT table_name, column_name, data_type
FROM user_tab_columns
WHERE data_type = 'DATE' OR data_type LIKE 'TIMESTAMP%'
ORDER BY table_name, column_name;
On Postgres, for the source side:
SELECT 'customers' AS t, COUNT(*) FROM customers
UNION ALL SELECT 'customers_ai', COUNT(*) FROM customers_ai
UNION ALL SELECT 'transactions', COUNT(*) FROM transactions
UNION ALL SELECT 'transactions_ai', COUNT(*) FROM transactions_ai;
SELECT table_name, column_name, data_type, numeric_precision, numeric_scale, character_maximum_length
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name IN ('customers','customers_ai','transactions','transactions_ai')
ORDER BY table_name, ordinal_position;
The timestamp failure
The first transfer loaded only transactions. For customers, Navicat generated inserts like this:
TO_DATE('2026-11-08 06:42:11.182712', 'SYYYY-MM-DD HH24:MI:SS')
Oracle rejected the first row with ORA-01830, because the format stops at seconds while the value carries microseconds. The inserts ran as one block, so that single error rolled back all 8 rows. transactions loaded because its txn_date is a plain date with no fractional seconds. The other two tables showed as terminated, with no error text I could read.
The Data Transfer options matter here. By default it drops and recreates the target tables, so any column type I fixed by hand in Oracle was lost on the next run. With drop unticked and continue-on-error ticked, the run still attempted the create step and logged ORA-00955 (name already used), which is harmless. The TO_DATE error came back for customers on those runs.
I first tried changing the Oracle columns to hold fractional seconds. That alone did not get the customers rows through:
ALTER TABLE "customers" MODIFY ("created_at" TIMESTAMP(6));
ALTER TABLE "customers_ai" MODIFY ("created_at" TIMESTAMP(6));
What worked was giving Navicat different source data. Run this in a Postgres query tab, not an Oracle one (I ran it against Oracle by mistake first and got ORA-02420, because CREATE SCHEMA and date_trunc are Postgres syntax):
CREATE SCHEMA oracle_src;
CREATE TABLE oracle_src.customers AS
SELECT customer_id, full_name, email, phone, national_id, county,
date_trunc('second', created_at) AS created_at
FROM public.customers;
In Data Transfer, I then set the source to oracle_src and ticked only customers. All 8 rows arrived. That means the microseconds were cut to whole seconds, which is a small loss of precision. customers_ai and transactions_ai loaded in full on the later runs, and I did not work out why customers_ai avoided the same error.
Keys
The Postgres source has a primary key on each of the four tables and one foreign key, from transactions to customers. This is how the keys turned out on Oracle, in order:
- First run: no primary or foreign keys on any Oracle table.
-
Later run: the log shows Navicat adding keys as a separate
ALTER TABLE ... ADD CONSTRAINTstep after the data. That step failed with ORA-02260 (table can have only one primary key), which suggests a key was already there from an earlier run. -
Final check from ADMIN:
customers,customers_aiandtransactions_aihad primary keys,transactionshad none, and none of the four tables had a foreign key. -
Manual repair: I added the missing
transactionskey by hand before the synchronization tests below.
These are the queries I used to compare. user_constraints only lists the logged-in user's own constraints, so from ADMIN I used dba_constraints and filtered by owner:
-- Postgres
SELECT conrelid::regclass AS tbl, conname, contype, pg_get_constraintdef(oid) AS def
FROM pg_constraint
WHERE connamespace = 'public'::regnamespace AND contype IN ('p','f')
ORDER BY 1, 2;
-- Oracle, as navicat_test
SELECT table_name, constraint_name, constraint_type, r_constraint_name
FROM user_constraints WHERE constraint_type IN ('P','R') ORDER BY table_name;
-- Oracle, as ADMIN
SELECT owner, table_name, constraint_name, constraint_type, status
FROM dba_constraints
WHERE owner IN ('NAVICAT_TEST', 'NAVICAT_TARGET')
AND constraint_type IN ('P', 'R')
ORDER BY owner, table_name, constraint_type;
Data Masking on Oracle
I ran Navicat's Hashing technique on full_name in customers_ai. It failed with PLS-00927: bind variable 'full_name' was not used and ORA-03066: invalid PL/SQL expression, after about 68 seconds, so the SQL Navicat generated for Oracle was not valid. The same masking tool worked on Postgres earlier. I did not try the other masking techniques on Oracle, so I cannot say whether the problem is specific to Hashing.
Oracle to Oracle: generation and synchronization
Data Generation, Structure Synchronization and Data Synchronization all worked on Oracle. I compared two Oracle schemas for the synchronization tests, because Structure Synchronization offered only same-engine targets in my build.
Data Generation
Data Generation filled an Oracle table with 1000 rows and 0 errors, but it was far slower than the local Postgres run. Navicat reported 0.00186 seconds on Postgres. On Oracle the window showed 06:14.974, which I read as 6 minutes 15 seconds. The target is a cloud database reached from Nairobi, which is a likely factor, but I did not test that.
CREATE TABLE gen_test (
id NUMBER(11) NOT NULL,
full_name VARCHAR2(100),
county VARCHAR2(50),
amount_kes NUMBER(12,2),
created_at TIMESTAMP(6)
);
I refreshed the tree, opened Data Generation on gen_test, and asked for 1000 rows. The log showed the run inside one transaction with a begin and a commit, then "Finished successfully". The TIMESTAMP(6) column generated without the date-format error that broke the transfer. Reading the rows back took 2.777 seconds:
SELECT COUNT(*) FROM gen_test;
SELECT * FROM gen_test FETCH FIRST 5 ROWS ONLY;
Setting up two Oracle schemas
Structure Synchronization only listed targets of the same engine as the source. With a Postgres source, the target side showed only Postgres, and with an Oracle source it showed only Oracle. Navicat's feature matrix labels only Data Transfer as working across server types, and lists the two synchronization tools without that label, which fits what I saw for Structure Synchronization. I did not check the target list for Data Synchronization.
So I built a second Oracle schema to compare against. In Database Actions as ADMIN, with your own password typed without the angle brackets:
CREATE USER navicat_target IDENTIFIED BY "<your password>";
GRANT DWROLE TO navicat_target;
ALTER USER navicat_target QUOTA UNLIMITED ON DATA;
I added a second connection, Oracle_navicat_target, with the same wallet and _low alias and the new user. Then I ran Data Transfer from Oracle_navicat_test to Oracle_navicat_target, ticking only transactions. It moved 200 records with 0 errors in 21.681 seconds. In the target, as navicat_target:
SELECT COUNT(*), SUM("amount_kes") FROM "transactions";
SELECT column_name, data_type, data_precision, data_scale
FROM user_tab_columns WHERE LOWER(table_name) = 'transactions' ORDER BY column_id;
SELECT constraint_name, constraint_type FROM user_constraints
WHERE LOWER(table_name) = 'transactions';
The target held 200 rows and a sum of 5121876.44, matching the source. The columns came through as NUMBER(11,0), NUMBER, NVARCHAR2 and DATE, and the structure comparison later showed no differences in any of them. The constraint query returned only check constraints and no primary key.
Data Synchronization matches rows by key, so I added a primary key to the table in both schemas, once as navicat_test and once as navicat_target:
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_pkey" PRIMARY KEY ("txn_id");
Structure Synchronization on Oracle
Structure Synchronization added the new column correctly, but Navicat pre-ticked two deletions on the target that I had to untick by hand. In the source I added a column:
ALTER TABLE "transactions" ADD ("channel" VARCHAR2(20));
I compared NAVICAT_TEST (source) with NAVICAT_TARGET (target). The screen listed one table to modify, transactions, and four to create, which were the tables that exist only in the source. I ticked none of the four. Inside transactions, Navicat showed channel as an addition. It also pre-ticked two red-X rows that would drop two system-named check constraints found only on the target, so I unticked them and left only channel.
The Deployment Script tab stayed empty until I clicked Next, which was confusing. The script then read:
ALTER TABLE "NAVICAT_TARGET"."transactions"
ADD ("channel" VARCHAR2(20 BYTE) VISIBLE)
The run reported 1 of 1 successful, 0 errors, 0.718 seconds. I confirmed the column in the target:
SELECT column_name, data_type, data_length FROM user_tab_columns
WHERE LOWER(table_name) = 'transactions' AND LOWER(column_name) = 'channel';
It returned channel, VARCHAR2, 20.
Data Synchronization on Oracle
Data Synchronization found exactly the one row I changed and applied it correctly. In the source:
UPDATE "transactions" SET "txn_type" = 'sync_test' WHERE "txn_id" = 1;
COMMIT;
The comparison for transactions showed 0 to insert, 1 to update, 0 to delete and 199 identical. I ran it, then checked the target:
SELECT "txn_id", "txn_type" FROM "transactions" WHERE "txn_id" = 1;
SELECT COUNT(*), SUM("amount_kes") FROM "transactions";
The row showed sync_test, the count was still 200 and the sum was still 5121876.44. I added the primary key on both sides before comparing, so I did not test what Navicat does without one.
Postgres to MySQL HeatWave on Oracle Cloud
MySQL HeatWave sits in a private network, so I reached it through a small jump host and Navicat's SSH tunnel. Once connected, Data Transfer, Structure Synchronization, Data Synchronization and Data Masking all finished with 0 errors. The transfer kept the primary keys and the foreign key, but it turned the SERIAL columns into plain integers and dropped the now() default.
Setting up MySQL HeatWave and a jump host
Each Oracle Cloud tenancy can have one Always Free MySQL HeatWave DB system in its home region (Oracle docs). It is a 1 ECPU MySQL node with 50 GB of storage and 50 GB of backup storage (Oracle announcement). It can also have a free single-node HeatWave cluster, which none of my tests used. The free VM options are two AMD micro instances with 1/8 OCPU and 1 GB of memory each, plus Ampere Arm instances (Oracle docs). Pick the option labelled Always Free or Always Free-eligible in each form, because other shapes are billed by the hour. These are the Always Free limits in Oracle's documentation when I tested, so check the current limits and your eligibility in your own tenancy before you create anything.
The database has no public IP address, so the path from your laptop is: Navicat, SSH on port 22 to a jump host in the public subnet, then TCP 3306 to the database in the private subnet. Values in angle brackets below are placeholders for your own.
-
Create the network. In the OCI console open Virtual Cloud Networks and start the VCN Wizard. Choose "Create VCN with Internet Connectivity", name it
<VCN_NAME>and keep the default ranges: VCN10.0.0.0/16, public subnet10.0.0.0/24, private subnet10.0.1.0/24. -
Let the public subnet reach MySQL. Open the private subnet, then its security list, and add an ingress rule: stateful, source type CIDR, source
10.0.0.0/24, protocol TCP, destination port 3306. This allows the whole public subnet, which is what I used for my throwaway test. Once the jump host exists (step 4), it is safer to replace the source with the jump host's private IP followed by/32, which its Details page shows. -
Create the DB system. Under Databases, MySQL HeatWave, DB Systems, click Create DB System and choose the Always Free option. Set an administrator username and password and store them safely. For networking choose your VCN and the private subnet. When the state is Active, open the Endpoints tab and note the private IP, which I call
<DB_PRIVATE_IP>, and port 3306. -
Create the jump host. Under Compute, Instances, click Create instance. I chose Oracle Linux 9, whose SSH user is
opc. Click Change shape, then Specialty and previous generation, and pickVM.Standard.E2.1.Micro, which carries the Always Free-eligible badge. Let OCI generate a key pair and click Save Private Key, because it is not shown again. Put the VM in the public subnet with a public IPv4 address, which I call<JUMP_HOST_PUBLIC_IP>. Also check the public subnet's security list, because the wizard's default rules may allow SSH from any address. If you can, limit port 22 to your own public IP, and update it when your IP changes, for example when you switch from Wi-Fi to mobile data. - Lock down the key and test SSH from your machine:
chmod 400 ~/Downloads/<KEY_FILE>
ssh -i ~/Downloads/<KEY_FILE> opc@<JUMP_HOST_PUBLIC_IP>
My SSH client warned that the connection was not using a post-quantum key exchange, and the login still worked.
- Check the database port from the jump host. This uses a Bash built-in, so nothing needs installing:
timeout 5 bash -c '</dev/tcp/<DB_PRIVATE_IP>/3306' && echo open || echo closed
It printed open. If yours prints closed, recheck the ingress rule from step 2.
-
Create the Navicat connection. Choose MySQL. On the General tab enter host
<DB_PRIVATE_IP>, port 3306 and your administrator user and password. On the SSH tab tick Use SSH tunnel, enter host<JUMP_HOST_PUBLIC_IP>, port 22, useropc, authentication method Public Key, and pick your key file with the...button. Leave the passphrase empty. On the Advanced tab I ticked Keepalive Interval and set it to 240 seconds.
After I confirmed the dialogs, the connection opened and listed information_schema, mysql, mysql_audit, mysql_option, mysql_tasks, performance_schema and sys. Running SELECT VERSION(), CURRENT_USER(); returned the server version 26.10.0-u1-cloud and ADMIN@%.
Four things confused me:
- Testing the connection before turning on the tunnel failed with
2002 - Can't connect to MySQL server on '<DB_PRIVATE_IP>' (110). A private IP cannot be reached from your laptop, so this error is expected until the SSH tab is set. - Navicat displayed a key passphrase prompt even though my key has none. It was an unencrypted RSA key (the file starts with
BEGIN RSA PRIVATE KEY) that plainsshaccepted. I left the field empty and confirmed, and the connection opened. I do not know why it prompted. A later password prompt looked like Ubuntu unlocking its login keyring for the saved connection password, though I did not confirm that. - After I had been away for a while, Test Connection timed out connecting to the jump host, while plain
sshfrom the terminal worked a few minutes later. I cannot tell the cause. The 240 second keepalive should reduce idle drops, but I have not proved that it prevents them. - The tunnel is Navicat's own, so nothing runs when Navicat is closed. If it keeps dropping for you, a tunnel started from your own terminal is the fallback.
Creating the test schemas and user
I ran this as the administrator. Use your own fake password in place of the placeholder:
CREATE DATABASE navicat_test CHARACTER SET utf8mb4;
CREATE DATABASE navicat_target CHARACTER SET utf8mb4;
CREATE USER 'navicat_user'@'%' IDENTIFIED BY '<FAKE_PASSWORD>';
GRANT ALL PRIVILEGES ON navicat_test.* TO 'navicat_user'@'%';
GRANT ALL PRIVILEGES ON navicat_target.* TO 'navicat_user'@'%';
SHOW GRANTS FOR 'navicat_user'@'%';
SHOW GRANTS returned three rows: USAGE on *.* and ALL PRIVILEGES on each of the two schemas. The '%' host is acceptable here because the DB system is only reachable from inside the private subnet.
I then duplicated the connection to log in as the new user. My first attempt still connected as ADMIN, because I had not changed the User Name field and the saved administrator password was still in place. This is how I caught it:
SELECT CURRENT_USER();
After I changed the user and retyped the password, it returned navicat_user@%, and the sidebar listed only information_schema, navicat_target, navicat_test and performance_schema.
Data Transfer from Postgres
First I recorded the Postgres source, so I could check the result:
SELECT table_name, column_name, data_type, column_default
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name IN ('customers', 'transactions')
ORDER BY table_name, ordinal_position;
SELECT conrelid::regclass AS tbl, contype, pg_get_constraintdef(oid) AS def
FROM pg_constraint
WHERE connamespace = 'public'::regnamespace;
SELECT (SELECT COUNT(*) FROM customers) AS customers,
(SELECT COUNT(*) FROM transactions) AS transactions,
(SELECT SUM(amount_kes) FROM transactions) AS sum_amount_kes;
The source had 13 columns across the two tables, SERIAL ids with nextval(...) defaults, a created_at timestamp with a now() default, primary keys on both tables and a foreign key from transactions to customers. The counts were 8 customers, 200 transactions and a sum of 5121876.44.
In Tools, Data Transfer, I set the source to my-local-postgres, database my_project_db, schema public, and the target to the navicat_user connection and the navicat_test schema. I ticked only customers and transactions and left the options at their defaults. The run reported 2 objects, 208 records processed and transferred, 0 errors and 4.907 seconds. I checked the result on MySQL as navicat_user:
SELECT (SELECT COUNT(*) FROM navicat_test.customers) AS customers,
(SELECT COUNT(*) FROM navicat_test.transactions) AS transactions,
(SELECT SUM(amount_kes) FROM navicat_test.transactions) AS sum_amount_kes;
SHOW CREATE TABLE navicat_test.customers;
SHOW CREATE TABLE navicat_test.transactions;
The counts were 8 and 200 and the sum was 5121876.44, the same as Postgres. This is what SHOW CREATE TABLE showed:
| Postgres source | MySQL result |
|---|---|
SERIAL ids |
plain int NOT NULL, with no AUTO_INCREMENT and no default |
numeric amount |
decimal(12,2) NOT NULL |
timestamp with default now()
|
datetime, with the default dropped (DEFAULT NULL) |
varchar(n) |
varchar(n), lengths kept |
| Primary keys | kept on both tables |
| Foreign key | kept, with the same name transactions_customer_id_fkey, plus an index on customer_id
|
| Engine and charset | InnoDB, utf8mb4, utf8mb4_0900_ai_ci
|
Two notes. The customer fields in the MySQL copy appear as asterisks, because those values were already masked in my Postgres customers table from an earlier masking run, and Data Transfer copies values as they are. And the transfer finished with 0 errors where the Oracle run failed on timestamps. I did not check whether my source timestamps had fractional seconds, so I cannot say what MySQL did with them, only that the new column is a datetime without a fractional part.
Structure Synchronization on MySQL
To compare two MySQL schemas, I used the same connection for both sides, with navicat_test as the source and navicat_target as the target. The target was empty, so the comparison listed both tables as missing. I ticked both, clicked Next, and the Deployment Script tab showed this:
SET FOREIGN_KEY_CHECKS=0;
CREATE TABLE `navicat_target`.`customers` (
`customer_id` int NOT NULL,
`full_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
`email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`phone` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`national_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`county` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`created_at` datetime NULL DEFAULT NULL,
PRIMARY KEY (`customer_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
CREATE TABLE `navicat_target`.`transactions` (
`txn_id` int NOT NULL,
`customer_id` int NULL DEFAULT NULL,
`amount_kes` decimal(12, 2) NOT NULL,
`txn_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`txn_date` date NULL DEFAULT NULL,
`channel` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
PRIMARY KEY (`txn_id`) USING BTREE,
INDEX `transactions_customer_id_fkey`(`customer_id` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
ALTER TABLE `navicat_target`.`transactions` ADD CONSTRAINT `transactions_customer_id_fkey` FOREIGN KEY (`customer_id`) REFERENCES `navicat_target`.`customers` (`customer_id`) ON DELETE RESTRICT ON UPDATE RESTRICT;
SET FOREIGN_KEY_CHECKS=1;
Navicat creates both tables first and adds the foreign key afterwards in a separate ALTER TABLE, so the table order cannot break it. The run reported 5 of 5 statements, 0 errors and 1.518 seconds.
Data Synchronization on MySQL
My first Data Synchronization ran before the target tables existed. It finished with 0 errors, but the comparison showed "Fail to locate target table" for both tables, and the script held only the two SET FOREIGN_KEY_CHECKS lines, so nothing was copied. A finished run with 0 errors does not mean any rows moved, so read the script. Run Structure Synchronization first.
After the structure was deployed, the comparison offered the rows to insert. The script was one INSERT per row, customers first, inside a transaction. Here are the first lines of each table:
INSERT INTO `navicat_target`.`customers` (`customer_id`, `full_name`, `email`, `phone`, `national_id`, `county`, `created_at`) VALUES (1, 'A**** W******', 'a*****w@e*********m', '+25******0001', '1*******', 'Nairobi', '2026-11-08 06:42:11');
INSERT INTO `navicat_target`.`transactions` (`txn_id`, `customer_id`, `amount_kes`, `txn_type`, `txn_date`, `channel`) VALUES (1, 3, 43551.81, 'payment', '2026-01-15', NULL);
The run reported 210 of 210 statements (8 customers, 200 transactions and the two SET lines), 0 errors and 5.902 seconds. I checked the target:
SELECT (SELECT COUNT(*) FROM navicat_target.customers) AS customers,
(SELECT COUNT(*) FROM navicat_target.transactions) AS transactions,
(SELECT SUM(amount_kes) FROM navicat_target.transactions) AS sum_amount_kes;
It returned 8, 200 and 5121876.44, matching the source. The log also showed a "Success: 5" next to 210 of 210, which I cannot explain, so I do not rely on that number.
Data Masking on MySQL
The customer values in my tables were already masked, so I made a small table of obviously fake rows to see the techniques at work:
CREATE TABLE navicat_target.masking_demo (
id INT PRIMARY KEY,
full_name VARCHAR(100),
email VARCHAR(100),
phone VARCHAR(20),
national_id VARCHAR(20)
);
INSERT INTO navicat_target.masking_demo VALUES
(1, 'Amina Wanjiru', '[email protected]', '+254700000001', '12345678'),
(2, 'Brian Otieno', '[email protected]', '+254700000002', '23456789'),
(3, 'Cynthia Mutua', '[email protected]', '+254700000003', '34567890');
In Data Masking I added four stages, one per column. The preview pane showed the input and the output for each stage before I ran anything.
| Column | Technique | Output |
|---|---|---|
full_name |
Substitution (Name) | A**** W******* |
email |
Hashing | a 32-character hex string |
phone |
Nulling | NULL |
national_id |
Substitution (String), preserving the first character and masking the rest with *
|
1******* |
The result is in the table itself. Masking changes the rows in place, so keep a copy of your unmasked data if you want to run it again. Hashing produced a one-way string instead of a readable email address. Predictable values such as email addresses can sometimes be guessed and matched against their hashes, so I would not treat plain hashing as enough anonymisation. The string is 32 characters of hexadecimal, but I did not check which algorithm Navicat used, so I will not name one. Nulling and substitution are the techniques that do not rely on a hash.
The AI features
Navicat's AI features work once connected, but you must supply your own model provider and API configuration. Costs, availability and output quality depend on the provider and model you choose.
Why a free model. Frontier models such as Claude from Anthropic, Kimi from Moonshot and GPT from OpenAI typically need API keys and may incur usage charges, so I used a free Nvidia Nemotron model through OpenRouter.
Setup. The AI options list DeepSeek, Ollama and an OpenAI Compatible provider. DeepSeek needed a paid top-up, and Ollama could not connect, because I had no local server running. I used the OpenAI Compatible option with OpenRouter and a free model. The ... button that should list models came back empty, so I typed the model ID by hand. My first free model returned a 429 rate-limit error from its upstream provider, and a second one (Nvidia Nemotron) passed Test Connection.
- Create an OpenRouter account and generate an API key. Keep the key private and never paste it into a post, a screenshot or a shared doc.
- In Navicat's AI settings, choose the OpenAI Compatible provider.
- Set the base URL to
https://openrouter.ai/api/v1. - Paste your API key.
- Type the model ID by hand.
- Click Test Connection. A free model can answer with a 429 rate-limit error, so if one fails, try another.
What I saw once connected:
-
Ask AI wrote a county-totals query where the join, grouping and sorting were correct in my first test, but it guessed the column name
amountwhen my column isamount_kes, so that query would not have run. I did not keep that first attempt, and the two queries below use the correct column. In later testing it gave me correct queries after attaching the schema. -
Explain Error correctly diagnosed a duplicate-column error on
risk_scoreand suggestedADD COLUMN IF NOT EXISTS. -
The chat panel suggests SQL and does not apply it. It generated an email-from-name query and a Swahili translation of transaction types as
SELECTstatements, and my emails stayed unchanged until I ran anUPDATEmyself. - Swahili quality was weak. It translated deposit as "Fedha", withdrawal as "Kuondoa" and transfer as "Badilisha", and only "Malipo" for payment looked plausible to me. That reflects the free model I chose, not Navicat.
-
Fill with AI and AI Table Designer are in the Linux build. I could not find them at first, then confirmed that both are there. By prompting the AI, I also had it create a fake
employeestable.
Because these features send your data to the model provider, I only used fake data. Do the same unless you know your provider's terms.
Queries from my AI testing
Two county-totals queries from the Ask AI tests:
-- county totals, version 1
SELECT
c.county,
SUM(t.amount_kes) AS total_transaction_amount
FROM customers AS c
JOIN transactions AS t ON t.customer_id = c.customer_id
GROUP BY c.county
ORDER BY total_transaction_amount DESC;
-- county totals, version 2
SELECT c.county, count(*) AS txns, round(sum(t.amount_kes), 2) AS total_kes
FROM transactions t
JOIN customers c USING (customer_id)
GROUP BY c.county
ORDER BY total_kes DESC;
The duplicate-column error that Explain Error diagnosed came from the first statement below, and the fix it suggested was the second:
ALTER TABLE customers_ai ADD COLUMN risk_score INT;
ALTER TABLE customers_ai ADD COLUMN IF NOT EXISTS risk_score INT;
The chat panel's suggestions were two SELECT queries, one building an email from the name and one translating the transaction types into Swahili:
SELECT
customer_id,
full_name,
lower(split_part(full_name, ' ', 1)) || '.' || lower(split_part(full_name, ' ', 2)) || '@example.com' AS email
FROM customers_ai
ORDER BY customer_id
LIMIT 10;
SELECT
txn_id,
customer_id,
amount_kes,
CASE lower(txn_type)
WHEN 'deposit' THEN 'Fedha'
WHEN 'withdrawal' THEN 'Kuondoa'
WHEN 'transfer' THEN 'Badilisha'
WHEN 'payment' THEN 'Malipo'
ELSE txn_type
END AS txn_type_swahili,
txn_date
FROM transactions_ai
ORDER BY txn_id
LIMIT 10;
Because they only read data, I checked afterwards that nothing had changed:
SELECT email FROM customers_ai LIMIT 3;
SELECT DISTINCT txn_type FROM transactions_ai;
What I did not test
This review covers a small, fake dataset, so treat it as a first look and not a benchmark.
-
Parts of MySQL. I did not insert a row without an id to see what happens after the
SERIALconversion, run a data sync that updates a row, add a column and sync the structure change, or check whether the source timestamps held fractional seconds. I also left the optional HeatWave cluster out of the tests. - Parts of Oracle. I tested Postgres to Oracle transfer, Oracle to Oracle transfer, Data Generation, Structure Synchronization, Data Synchronization and one masking technique. I did not test the other masking techniques on Oracle, a Data Synchronization with Oracle on one side and another engine on the other, or Oracle with real data.
- Real or large data. My biggest table had about 1,000 rows, so I have no performance numbers beyond the timings above.
- Other platforms and team features. I used one Linux machine and no shared connections.
- Stronger AI models. Every AI result came from one free model.
Verdict
Navicat 18 is a solid single window for masking, copying and syncing databases with clear previews, but cross-engine moves need manual checks and its AI features depend on the model you bring.
It suits you if you work across several database engines and want to copy, compare and sync data from one GUI, with the SQL shown before it runs. The masking tool is useful for making test copies, as long as you pick techniques by risk.
Be cautious if you plan cross-engine migrations. Postgres to SQLite kept the data and keys but dropped auto-numbering and defaults. Postgres to MySQL kept the keys and the foreign key but turned SERIAL into a plain int and dropped the now() default. Postgres to Oracle mapped timestamps to DATE, and the customers rows failed on fractional seconds until I trimmed them at the source, and the default options rebuild the target tables on every run. Connecting to an Autonomous Database from Linux also took considerable setup, and reaching a private MySQL HeatWave database needed a jump host and an SSH tunnel, which is cloud setup and not a Navicat flaw, but plan for it. Structure Synchronization only offered same-engine targets in my build, so I compared two Oracle schemas instead of Postgres with Oracle, and Navicat pre-ticked two deletions that I had to untick, so read the script before you run it. Also be cautious if you expect the AI features to work out of the box: connecting a model took several attempts, and what you get depends on the model you bring.
Top comments (0)